Tiny Defense: Using UIScrollView in Cocos2d by Sash

So as you know that I spent the weekend trying to get xcode, objective c and cocos2d to be my bitch.

Most of my time I was working on a solution to implementing scrollviews in cocos2d. Turns out this isn’t as straight forward as I thought, but I found a tutorial over at getsetgames.com which gave me a good place to start. I have to warn you, the code in that tutorial is pretty messy. I’ve cleaned it up a lot below.

The Problem

Cocos 2d has no equivalent to UIScrollView. You can’t even use the regular UIScrollview, since it doesn’t fit into the framework. This leaves you with two possible solutions:

  1. Reverse engineer the UIScroll View
    This would take a long time to get right. Apple has put a lot of polish into making the touch interactions perfect. There are a ton of edge cases and the code is not available to copy. Ouch.
  2. Implement an invisible UIScrollView
    And use its contentOffset property to position your CCNode. Much better!

The later solution is much cleaner and has a straight forward implementation. Basically it works like this. We subclass UIScrollView, make sure it is invisible, put it on top of Cocos2d so that the user is interacting with it, then take the scroll position and apply it to the stuff in cocos2D each frame. There are a few challenges that we will have a look at, but check out the code I wrote first:

Code: CCScrollView.h

#import "cocos2d.h"
 
@interface CCScrollView : UIScrollView
 
+ (CCScrollView *) makeScrollViewWithWidth:(int)width Height:(int)height;
 
- (void)setWidth:(int)width Height:(int)height;
- (int) getOffset;
- (CGPoint) getOffsetAsPoint;
 
@end

Code: CCScrollView.m

#import "CCScrollView.h"
 
@implementation CCScrollView
 
/*
* Returns a CCScrollView Object.
* You can treat this like a regular UIScrollView, but don't add subviews. Instead
* use the methods getOffset and getOffsetAsPoint to set the Y Position of the
* CCNode you want to scroll each frame
*/
+(CCScrollView *) makeScrollViewWithWidth:(int)width Height:(int)height
{
   CCScrollView * scrollView =
     [[CCScrollView alloc] initWithFrame:[UIScreen mainScreen].applicationFrame];
   scrollView.contentSize = CGSizeMake(width, height);
   scrollView.showsHorizontalScrollIndicator = NO;
   scrollView.showsVerticalScrollIndicator = NO;
   [scrollView setUserInteractionEnabled:TRUE];
   [scrollView setScrollEnabled:TRUE];
 
   [[[CCDirector sharedDirector] openGLView] addSubview:scrollView];
 
   return scrollView;
}
 
/*
* Change the size of the scrollable area
*/
- (void)setWidth:(int)width Height:(int)height
{
   self.contentSize = CGSizeMake(width, height);
}
 
/*
* Returns the offset of the scrollview as a point
*/
- (CGPoint) getOffsetAsPoint
{
   CGPoint offset = [self contentOffset];
   offset = [[CCDirector sharedDirector] convertToGL: offset];
   offset.y *= -1;
   offset.x *= -1;
 
   return offset;
}
 
/*
* Returns the Y co-ordinate of the offset of the ScrollView
*/
- (int) getOffset
{
   CGPoint offset = [self getOffsetAsPoint];
   return offset.y;
}
 
- (void) dealloc
{
   [self removeFromSuperview];
   [super dealloc];
}
 
/*
* Override touch functions
* This allows Cocos2d to process touches
*/
-(void) touchesBegan: (NSSet *) touches withEvent: (UIEvent *) event
{
   if (!self.dragging)
      [[[CCDirector sharedDirector] openGLView] touchesBegan:touches withEvent:event];
 
   [super touchesBegan: touches withEvent: event];
}
 
-(void) touchesEnded: (NSSet *) touches withEvent: (UIEvent *) event
{
   if (!self.dragging)
      [[[CCDirector sharedDirector] openGLView] touchesEnded:touches withEvent:event];
 
   [super touchesEnded: touches withEvent: event];
}
@end

Code: Implementation.m

//
//  Implementation.m
//  TinyDefense
//
//  Created by Sasha MacKinnon on 21/09/11.
//  Copyright 2011 Bit Battalion. All rights reserved.
//
 
#import "Implementation.h"
 
@implementation Implementation
 
+ (CCScene *) scene
{
    CCScene * s = [CCScene node];
    Implementation * l = [Implementation node];
    [s addChild:l];
 
    return s;
}
 
- (id)init
{
    if (self = [super init])
    {
        gameNode = [[CCNode alloc] init];
 
        //add the background to the game
        background = [CCSprite spriteWithFile:@"background.png"];
        background.position = ccp(160, 480);
        [gameNode addChild:background];
        [self addChild: gameNode];
 
        //set up scrolling
        scrollView = [CCScrollView makeScrollViewWithWidth:320 Height:960];
 
        [self schedule:@selector(enterFrame:)];
    }
 
    return self;
}
 
- (void) enterFrame: (ccTime) dt
{
    gameNode.position = ccp(0, [scrollView getOffset]);
}
@end

Setting up the UIScrollView

We want an easy way to create scroll views without having to go through the hassle of manually allocing and setting properties every time w need a new one. The makeScrollViewWithWidth method does just this. It:

  • creates the scroll view,
  • sets its content size to the arguments
  • adds itself to the OpenGLView, so it appears on the screen
+(CCScrollView *) makeScrollViewWithWidth:(int)width Height:(int)height
{
    CCScrollView * scrollView =
       [[CCScrollView alloc] initWithFrame:[UIScreen mainScreen].applicationFrame];
    scrollView.contentSize = CGSizeMake(width, height);
    scrollView.showsHorizontalScrollIndicator = NO;
    scrollView.showsVerticalScrollIndicator = NO;
    [scrollView setUserInteractionEnabled:TRUE];
    [scrollView setScrollEnabled:TRUE];
 
    [[[CCDirector sharedDirector] openGLView] addSubview:scrollView];
 
    return scrollView;
}

The contentOffset Property

Next we need is to be able to access the position of our ScrollView after the user has scrolled. Since CCScrollView is a subclass of UIScrollView, we could access the “contentOffset” property directly, but we need to do a few transformations on that before hand to make it work the way we want. I made getter methods to make life easier:

- (CGPoint) getOffsetAsPoint
{
    CGPoint offset = [self contentOffset];
    offset = [[CCDirector sharedDirector] convertToGL: offset];
    offset.y *= -1;
    offset.x *= -1;
 
    return offset;
}
 
- (int) getOffset
{
    CGPoint offset = [self getOffsetAsPoint];
    return offset.y;
}

Making sure touches are registered by cocos2D

Right now we can have elements scrolling on the screen, but we can’t touch any of them. This is because CCScrollView is sitting on top of cocos2d, so the touches stop being processed before they hit cocos2D. To solve this we override touchBegan and touchEnd, and pass the touches down to cocos2D

-(void) touchesBegan: (NSSet *) touches withEvent: (UIEvent *) event
{
    if (!self.dragging)
        [[[CCDirector sharedDirector] openGLView] touchesBegan:touches withEvent:event];
    [super touchesBegan: touches withEvent: event];
}
 
-(void) touchesEnded: (NSSet *) touches withEvent: (UIEvent *) event
{
    if (!self.dragging)
        [[[CCDirector sharedDirector] openGLView] touchesEnded:touches withEvent:event];
    [super touchesEnded: touches withEvent: event];
}

Fixing the content offset bug

But it STILL won’t work. Cocos2D, for some weird reason, isn’t prepared to deal with the touches we get from UIScrollView. It offsets the touches by a small margin when you scroll down far enough. I found a great solution online, which requires a quick change to CCNode.m

http://stackoverflow.com/questions/2457380/uiscrollview-and-cocos2d

Basically replace these functions in CCNode.m:

- (CGPoint)convertTouchToNodeSpace:(UITouch *)touch {
    // cocos2d 1.0 rc bug when using with additional overlayed views (such as UIScrollView).
    // CGPoint point = [touch locationInView: [touch view]];
    CGPoint point = [touch locationInView: [[CCDirector sharedDirector] openGLView]];
    point = [[CCDirector sharedDirector] convertToGL: point];
    return [self convertToNodeSpace:point];
}
 
- (CGPoint)convertTouchToNodeSpaceAR:(UITouch *)touch {
    // cocos2d 1.0 rc bug when using with additional overlayed views (such as UIScrollView).
    // CGPoint point = [touch locationInView: [touch view]];
    CGPoint point = [touch locationInView: [[CCDirector sharedDirector] openGLView]];
    point = [[CCDirector sharedDirector] convertToGL: point];
    return [self convertToNodeSpaceAR:point];
}

and CCMenu.m

-(CCMenuItem *) itemForTouch: (UITouch *) touch {
    // cocos2d 1.0 rc bug when using with additional overlayed views (such as UIScrollView).
    // CGPoint touchLocation = [touch locationInView: [touch view]];
    CGPoint touchLocation = [touch locationInView: [[CCDirector sharedDirector] openGLView]];
    touchLocation = [[CCDirector sharedDirector] convertToGL: touchLocation];
    ...

And now it’s ready to go!! The code is here in full, so feel free to copy paste these classes.

Fixing it for iOS 5

After all this, it might still not work with iOS 5. Instead of explaining why, I’ll point you to a stack overflow answer that has the solution:

Animation in OpenGL ES view freezes when UIScrollView is dragged on the iPhone

Whats Next

I’ve done a ton of work since I made this video which I’ll post in a few days. Tonight, I’m travelling back to Australia! I’m hoping to get a bit of code written on the plane before my battery dies. Wish me luck!

  • Twitter
  • Facebook
  • StumbleUpon

Discussion

  1. Stephen Broadfoot says:

    I wish Obj-C weren’t so Smalltalk-ish.

  2. Jack says:

    Where is the “implementation.h” ?

  3. Sash says:

    Implementation.h is actually pretty trivial! Implementaiton.m is also just an example of how you would implement CCScrollView, so hopefully there will be no need to copy paste the whole thing.

    Stevie, I wish Obj-C weren’t so c-ish. I forgot how much I disliked it heh.

  4. Sam says:

    Smalltalk makes Obj-C awesome

  5. Martin Wells says:

    Awesome… but this wont work on iOS5!

  6. colinator says:

    Martin: why won’t it work on iOS5?

  7. colinator says:

    I’ve verified that it doesn’t work in the iOS 5 simulator. Will check device later. It looks like while a touch is down and dragging the scrollview, the scheduled enterFrame method doesn’t get called. I can ‘fix’ this by changing to the time-based director (and uncommenting those lines in CCDirector that deal with UIScrollView). Is there a problem with the display-link director under iOS5?

  8. Sash says:

    Martin and Colinator – I updated the post to point to an answer to the bug in IOS 5. Seems like they changed they way OpenGL ES View and UIScrollView interact in the lastest OS. Weird huh.

  9. arqam says:

    I’ve tried using your code. It works fine in simulator, but isnt working on the device. Tried replacing NSDefaultRunLoopMode with NSRunLoopCommonModes. Still didnt work.

  10. Rich says:

    Thank you so much!!

    The “Fixing the content offset bug” section just saved me.

  11. M Sud says:

    This article helped me more than you could fathom.

    If you live in the GTA, I would like to take you out for a beer .

  12. JamesBoxCat says:

    Sash, your a awesome. If your in Los Angeles, CA. I’ll buy you a few beers. =)

  13. Fille says:

    Just what I need! Will try it out very soon. Thanx.

  14. Fille says:

    Just what I need! Will try it out very soon. Thanx.

  15. Fille says:

    Tried this on iOS 5.1.1 using Cocos2D 2.0 and it works for a short while, but at a random time from running it app the deceleration part gets removed. I can drag its around while holding the finger down but as soon as I let go it stops.

    Anyone got this working on iOS 5.x ?

Leave a Comment