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:
- Layout scroll view and subviews for penguins.
- When the scroll view scrolls, identify if a penguin view is inside/outside the focus area or intersecting with it.
- If it is intersecting, make the area outside the focus area gray and leave the everything inside as colored.
- 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:
- 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.
- 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.
- 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.
- 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:
- 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.
- 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.
- 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.





Comments (22)