Implement “Focus Area” Special Effect in iPhone Apps

Nowadays, the competition in Apple App Store is so strong that it takes almost a perfect product and some luck to be successful there. You can’t control luck, then you got to make your product close to perfect. For games, that means not only fantastic game play and graphics, but also a lot of tiny details here and there. In my recent game “PenguinLinks 2″, I implemented a special effect in the level choosing screen, which I call “Focus Area”, to enhance the user experience. I don’t know how many users it has gained for me, but I certainly had a lot fun when I implemented it. That’s the topic we gonna cover in this post.

A picture is worth a thousand words. Then a video must be worth more than a million. Now let the video to explain what the effect look like.

As you can see, in my level choosing screen, there is a light blue box in the center, which is the “focus area”. The level (represented by a Penguin) inside the focus area is the chosen level. The trick is, when you slide the levels, the penguins move in and out the focus area; and when they are outside, they turn gray; when they are inside the box, they stay as colored image. When they are on the edge, they are actually half-colored, half-gray! Isn’t that cool? That’s what I am going to share with you today (sorry the low resolution video makes the effect not as obvious. But if you run the sample project I provided later, you will see it clearly).

To implement this, there are a few key steps:

  1. Layout scroll view and subviews for penguins.
  2. When the scroll view scrolls, identify if a penguin view is inside/outside the focus area or intersecting with it.
  3. If it is intersecting, make the area outside the focus area gray and leave the everything inside as colored.
  4. Last but not the least: performance.  As you can imagine, to do everything above needs a lot of computation and rendering. But we still want everything to be as smooth as possible.

In this post, I’ll implement the focus area effect with a vertical scroll view instead the horizontal one in my game.

1. Layout

Following is what it look like. On the left is from Interface Builder, on the right is the final result.

The “focus area” is the area demonstrated by the blue rectangle, which is a UIImageView — I named it “imageFrame“; the scroll view is behind the image view, and the views of penguins will be put on it programmatically in a serial fashion. Please note imageFrame is not a subview of scrollView, so that when the scroll view scrolls, the focus area stays at the center.

In viewDidLoad method, I did following to lay out penguins on the scroll view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- (void)viewDidLoad
{
    // 1. setup scrollView
    scrollView.clipsToBounds = NO;		// if set to yes, the area outside scrollView will not be rendered
    scrollView.scrollEnabled = YES;
    scrollView.showsHorizontalScrollIndicator = NO;
    scrollView.showsVerticalScrollIndicator = NO;
    scrollView.directionalLockEnabled = YES;
    scrollView.bounces = YES;
 
    // pagingEnabled property default is NO, if set the scroller will stop or snap at each photo
    // if you want free-flowing scroll, don't set this property.
    scrollView.pagingEnabled = YES;
 
    // 2. setup the scrollview for multiple images and add it to the view controller
    float imageViewWidthHeight = imageFrame.bounds.size.height;// * 0.7f;
    CGFloat currentYLocation = 0.5 * scrollView.frame.size.height - 0.5 * imageViewWidthHeight;
 
    for (int i = 1; i <= TOTAL_IMAGES; i++) {
        // position all image subviews in a vertical serial fashion
        CGRect frame = CGRectMake(0.5 * scrollView.frame.size.width - 0.5 * imageViewWidthHeight, currentYLocation,
                                  imageViewWidthHeight, imageViewWidthHeight);
 
        UIImage *image = [UIImage imageNamed:[NSString stringWithFormat:@"penguin%d.png", i]];
 
        GrayMaskView *maskView = [[GrayMaskView alloc] initWithFrame:frame];
        [maskView setImage:image];
 
	[scrollView addSubview:maskView];
 
        currentYLocation += scrollView.frame.size.height; // distance between images        
        [maskView release];
    }
 
    // 3. set the content size so it can be scrollable
    [scrollView setContentSize:CGSizeMake(scrollView.frame.size.width, TOTAL_IMAGES * scrollView.bounds.size.height)];
 
    // make unfocused levels gray
    [self scrollViewDidScroll:scrollView];
}

I don’t want to explain it too much since how to setup a scroll view is not the focus of this post. Just a few things need attention:

  1. In line #4, you must set “clipsToBounds” to NO, otherwise its subviews will disappear once they scroll outside the scroll view boundary — which is not what we want.
  2. Line #13, setting “pagingEnabled” to YES makes the scroll view only stops at “multiples of the scroll view’s bounds”. In our case, it means the scroll view only stops at the position where a penguin is found.
  3. Line #26, GrayMaskView is a subview of UIView, which I created to do all the magic for this special effect. The penguin image will be rendered there. We’ll cover it later.
  4. Line #36, when you use paginated scroll view, it’s important to set the content size correctly. Otherwise the scroll view won’t’ stop at the right position.

2. Calculate the gray and colored area

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#pragma mark -
#pragma mark UIScrollViewDelegate Methods
 
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    for (UIView *subView in self.scrollView.subviews) {
        if ([subView isKindOfClass:[GrayMaskView class]]) {
            CGRect tmpRect = [self.view convertRect:self.imageFrame.frame toView:self.scrollView];
            CGRect imageFrameInSubViewCoordinates = [self.scrollView convertRect:tmpRect toView:subView];
	    CGRect intersection = CGRectIntersection(imageFrameInSubViewCoordinates, subView.bounds);
 
	    if (CGRectIsNull(intersection)) {
                // outside the scrollView frame, mask the entire view
		((GrayMaskView*)subView).maskedRect = subView.bounds;
            } else {
                CGRect mRect = CGRectNull;
 
                // needs to flip
		if (intersection.origin.y == 0.0) {
		    // intersect at bottom edge
                    mRect = CGRectMake(0.0, 0.0, subView.bounds.size.width, subView.bounds.size.height - intersection.size.height);
		} else {
                    mRect = CGRectMake(0.0, intersection.size.height, subView.bounds.size.width, subView.bounds.size.height - intersection.size.height);
		}
 
		if (CGRectIsNull(mRect) || mRect.size.width == 0.0 || mRect.size.height == 0.0) {
                    ((GrayMaskView*)subView).maskedRect = CGRectNull;
		} else {
                    ((GrayMaskView*)subView).maskedRect = mRect;
		}
            } // if (CGRectIsNull(intersection)) ... else ...
        } // if ([subView isKindOfClass:[GrayMaskView class]])
    } // for ...
}

To figure out which area of the penguin should be gray and which area should be colored, we need to first find out the intersection of the penguin uiview (GrayMaskView) and the focus area (imageFrame); and if the intersection is null, it mean the GrayMaskView is outside the focus area, so it should be all gray. That’s the case in Line #13. The property “maskedRect” of GrayMaskView is the area to be masked in gray. This part is obvious.

But it gets trickier when the two views do intersect with each other. There are two cases for intersection:

The first case is intersection from top: in this figure, the intersected area is S1, and the area we want to mark as gray is area A; the second case is intersected from bottom: in this figure, the intersected area is S2 and the area we want to mark as gray is area B.

To get the intersection area, we can use method “CGRectIntersection”. But the two rectangles must be in the same coordinate system. In our case, the imageFrame view (the focus area, blue box) and two GrayMaskView(red or orange boxes) are in different coordinate system because they have different parent view. So we have to do a two-step conversion to convert the imageFrame’s frame to GrayMaskView’s coordinate system. That’s what line #7 and #8 does.

Once we know the intersection area, next step is to figure out the gray area. It looks pretty simple — if the intersection’s origin.y is 0, it’s the case #2, the orange box (don’t forget the intersection is in GrayMaskView’s coordinate system). So the CGRect for area B should be [listing 1]:

    mRect = CGRectMake(0.0, intersection.frame.size.height, subview.frame.size.width, subview.frame.size.height - intersection.frame.size.height);

Right? But hold on a second, in scrollViewDidScroll method I posted above, at line #20, the code to generate the rectangle is:

    mRect = CGRectMake(0.0, 0.0, subView.bounds.size.width, subView.bounds.size.height - intersection.size.height);

Was I wrong in the method? No, I was right. [Listing 1] is actually wrong! Why is that? This leads to the next step.

3. Render the image

GrayMaskView is a subview of UIView. It has a property, “maskedRect”, which is the rectangle needs to be masked into gray.

1
2
3
4
5
6
7
8
9
10
- (void) setMaskedRect: (CGRect)rect {
	if (!CGRectIsNull(maskedRect)) {
		if (CGRectEqualToRect(maskedRect, rect)) {
			return;
		}
	}
 
    maskedRect = rect;
    [self setNeedsDisplay];
}

In this setter method, once the maskedRect is updated, we re-render the view immediately by calling “[self setNeedsDisplay]“, and the system will call drawRect to update the UI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- (void)drawRect:(CGRect)rect {
    CGContextRef context = UIGraphicsGetCurrentContext();
 
    // flip orientation
    CGContextTranslateCTM(context, 0.0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
 
    CGContextDrawImage(context, drawingRect, corloredImage.CGImage);
 
    if (!CGRectIsNull(maskedRect)) {
        CGFloat scale;
        if ([corloredImage respondsToSelector:@selector(scale)]) {
            scale = corloredImage.scale;
        } else {
            scale = 1.0;
        }
 
        CGImageRef imageRef = CGImageCreateWithImageInRect([corloredImage CGImage], CGRectMake(0, 0, corloredImage.size.width * scale, corloredImage.size.height * scale));
 
        CGContextSetBlendMode(context, kCGBlendModeColor);
 
        // this is important to retain transparency in original image
        CGContextClipToMask(context, drawingRect, imageRef);
 
        CGContextSetRGBFillColor(context, 0.0, 0.0, 0.0, 1.0);
        CGContextFillRect(context, maskedRect);
 
        CFRelease(imageRef);
    }
}

In this method, line #5 and #6, we flipped the orientation. The reason is Quartz 2d uses a different coordinate system, in which the origin is in the lower left corner, instead of top left corner. So if we don’t do this flipping, when “CGContextDrawImage” is called later (line #8), the image will be rendered upside down. Now go back to the previous section, the reason why “listing 1″ is wrong is, it still uses the top left corner as the origin.

After flipping the orientation, we first draw the colored image, as seen in line #8. From line #18 to line #26, the masked rectangle is redrew into gray. In line #18, we created a copy of image data from the existing colored image. Please pay attention to variable “scale” here, which is calculated from line #11 to line #16. For retina display, the scale is 2, and for older devices, it’s 1. Since the “scale” property of UIImage is only available from iOS 4.0, we have to manually set scale to 1 for devices with older version (which is try anyway).

Another thing needs special attention is line #23. It’s important to call “CGContextClipToMask” before actually rendering the image. Otherwise the gray image will lose its alpha channel, and all transparent area will become black.

4. Performance Tuning

Now we have successfully achieved our goal — render a view half-gray, half-colored. However, if you try this example on a real device, specially older device, you will find when you drag the scroll view back and forth, it doesn’t feel smooth at all! Even on a simulator, when you do that, you can see CPU usage spikes!

Actually it’s not a surprise. As you can imagine, to implement this effect, there are a lot of computation and rendering going on. And the worst part is, when scroll view is moving, the system will continuously do computation and rendering again and again. So we need to do some performance tuning.

There are a few key points to save resource, specially CPU cycles and graphic engine:

  1. Make the image EXACTLY the same size as the GrayMaskView, where the image will be rendered. This is probably the most important thing to do in our case. Different size images will cause system doing resizing again and agin, which is a disaster here. If you can’t pre-make the image the exact size, don’t worry, the GrayMaskView already does that for you. When an image is assigned to GrayMaskView, it automatically resize it to the correct size.
  2. Don’t do unnecessary dynamic rendering. When a GrayMaskView is outside the focus area completely, it is always a gray image, no matter how it moves. So a gray image can be pre-made and when the view is detected outside the focus area, it just render the pre-made gray image. No computation. No dynamic rendering. And the best news is, GrayMaskView also does that for you already.
  3. Don’t render the invisible part. If you have many views laid out on the scroll view, it’s likely you only have a few visible in the main view. For all other invisible views, don’t do these complex rendering. Just ignore them.

By doing all these, the performance has improved dramatically. Even on a iPhone 2G, you can still scroll back and forth without much jaggy feeling.

That’s pretty much about this topic. The demo project can be found here, which contains the core class GrayMaskView. Give it a try and let me know what you think. And if you want to see what this effect looks like in real life, you can download my game Penguin Links v2 at: http://itunes.apple.com/us/app/penguin-links-v2/id443106263?mt=8.

:-D

 
Add a comment

Comments (22)

  1. Paul, December 11, 2011
    Hi, me again! I have this working perfectly in horizontal pretty much the same as your penguin demo. However what I am trying to do now it for example have the images to the left and right of the focus area to be showing more of each so on the left there is nearly all of a gray image on view then the colour image in focus area and then on the right nearly all of the gray image. I have this working but when I scroll the paging seems to be out as when I scroll it scrolls too far by scrolling past the next one in sequence and stops at the third image with a quater of it outside the focus area? Have been trying for ages changing the scrollview size etc but no joy? are you able to help? Thanks Reply
  2. Paul, November 28, 2011
    Hi, have fixed the issue! As I am using storyboard to create the interface the option "resize view from nib" was checked. This was odd as although the scrollview appeared on screen correctly along with the masked views it reported its y coordinate as being 320 pixels lower than what it was? After unchecking "resize view from nib" the problem was removed? ;-) Reply
  3. Paul, November 28, 2011
    HI, this would make sense accept the very first view is inside the imageframe and so does intersect? Only when I move it a fraction does it fire and become not CGRectNull and becomes colour inside the imageFrame? Reply
  4. yuchen, November 27, 2011
    @paul: for a rect, when it's (inf, inf) (0, 0), that means it's CGRectNull -- basically two views in method CGRectIntersection don't have any overlap. You can use CGRectIsNull to test it. Here is a good article regarding this concept: http://jamesjennin.gs/post/880892611/fun-with-cgrect Reply
  5. Paul, November 27, 2011
    Yuchen, I have my app working fine apart from a small glitch that is perplexing? Upon first load of my app the image inside imageFrame is gray, until I move it only slightly. I have found that in the viewDidLoad call to scollViewDidScroll this section of code gets run: CGRect tmpRect = [self.view convertRect:self.imageFrame.frame toView:self.scrollView]; CGRect imageFrameInSubViewCoordinates = [self.scrollView convertRect:tmpRect toView:subView]; CGRect intersection = CGRectIntersection(imageFrameInSubViewCoordinates, subView.bounds); if (CGRectIsNull(intersection)) { // outside the scrollView frame, mask the entire view ((GrayMaskView*)subView).maskedRect = subView.bounds; } else { ... } When I query intersection the rect is equal to x=inf y=inf width=0 height=0 I do not understand what the inf values are in this rect? Any ideas? Reply
  6. Paul, November 26, 2011
    Fully working horizontal scrolling on iPad :-0 Thanks for great tutorial ;-))))) Reply
  7. Paul, November 26, 2011
    Hi, yes I forgot to add the delegation section to my view controller for the scrollview ;-) Now I am trying to get the horizontal scrolling to work ;-) thanks Reply
  8. yuchen, November 25, 2011
    @paul: for how to do in in a horizontal way, please see my comments to @codeengine . Reply
  9. Yuchen, November 25, 2011
    @paul: I just tried the demo project iteself on an iPad and it seemed working fine.Maybe there is something wrong in the integration with your project? Reply
  10. Paul, November 24, 2011
    Hi, awesome effects and code sample, thanks! I just tried this code "as is" in an iPad project but it does not work the same. For example the the first image is coloured, no images on top but 4 below in gray and when I scroll the 5th is coloured, so basically 1st and last image remain coloured even outside imageFrame and the others remain gray. I was going to see how it work as is on iPad first then work through converting to horizontal full size but stumped ATM. Any idea what might be wrong? Thanks Reply
  11. yuchen, October 19, 2011
    @codeengine: if you want to implement the same thing with horizontal scrollview, all you need to change is in step 1 and 2: viewDidLoad and scrollViewDidScroll: . Just got though the code and it's actually not that hard to figure out what to change. You can download the sample project here: http://www.clingmarks.com/FocusAreaDemo.tgz Reply
  12. code engine, October 18, 2011
    Hello Sir, I am encountering same implementation but i have to implement horizontal scrollview as shown in above video can you tell me please where to change the code it will be a great help .I am new to iphone so please help me out. And many many thanks for contributing such a wonderful post Reply
  13. Marcio Andrey Oliveira, August 18, 2011
    This effect is awesome. Thanks a lot for sharing. Reply
  14. yuchen, July 29, 2011
    @ken, yeah, the effect is subtle. But it's more obvious on a real phone than the video. And there is a lot of fun just from doing that, isn't it? ;-) Reply
  15. Ken, July 29, 2011
    When I first watched the video I thought "So what? You put a blue semi-transparent image over top of the penguins." I didn't even notice that the images turned gray when they moved out of the focus area until you mentioned it and I watched the video again. :-) It's a neat effect, but it's quite subtle since, by definition, the user is focusing on the middle "focus" area. Reply
  16. yuchen, July 29, 2011
    @thadre: the project file was just uploaded. Find the link at the end of the project. thanks. Reply
  17. thadre, July 28, 2011
    Wow that looks really cool. Could you provide a working sample project with that code? Thx Reply

Add a comment

Top
(it will not be shared)