One of the dilemmas that many game engines are facing is whether they should provide many built-in high-level UI gadgets for developers, e.g. scroll view. Some of them feel that game engines should be condense and only focus on providing good gaming-related solutions, like graphics and sounds. However, as developers, we really need some neat UI gadgets for our game. Recently, I was trying to build a vertical scroll view for my iPhone game using cocos2d. Now I’d like share some pains and gains during the process.
Before implementing my own scroll view, I searched on the Internet and found some extension libraries for cocos2d. There is a scroll view for cocos2d, CCScrollLayer. Unfortunately, this gadget is more like a page viewer, not a viewer for a list of items. So I decided to build my own version. Below is a scroll view capture from my game that is under developing. Of course, if you prefer to read the code file directly, you can download it from here. Yet I suggest you to skim this blog to get the general idea of how to implementing scroll view, and it is going to help you understand raw code.

Designing Phase
From CCScrollLayer, I borrowed some design ideas.
1. This gadget is inherited from CCLayer in order to receive touch events.
2. Every item in the list is a sub-layer of scroll view, and we need to pass down touch events to items in the list. Every item is a sub-class of CCLayer, i.e., we can have buttons, images and everything in the item layer.
Implementing Phase
If you want to follow my steps to build a simple scroll view, it is better grabbing some cookies and drinks before starting, we will have a lot of coding work, but most importantly, have fun!
Step 1: Create a scroll view class
Create a scroll view layer class, and write a basic initialization method for use like below.
1: @interface FGScrollLayer : CCLayer
2: {
3: // Holds pages.
4: NSMutableArray *layers_;
5:
6: // Holds current pages width offset.
7: CGFloat pagesOffset_;
8:
9: // Holds the height of every page
10: CGFloat pageHeight_;
11:
12: // Holds the width of every page
13: CGFloat pageWidth_;
14:
15: // Holds the maximum upper position
16: CGFloat maxVerticalPos_;
17:
18: // Holds the real responsible rect in the screen
19: CGRect realBound;
20: }
21:
22: /** Offset, that can be used to let user see next/previous page. */
23: @property(readwrite) CGFloat pagesOffset;
24:
25: /** Page height, this version requires that each page shares the same height and width */
26: @property(readonly) CGFloat pageHeight;
27: @property(readonly) CGFloat pageWidth;
28:
29: #pragma mark Init/Creation
30:
31: /** Creates new scrollLayer with given pages & width offset.
32: * @param layers NSArray of CCLayers, that will be used as pages.
33: * @param pageSize indicates the size of every page, now this version requires each page
34: * share the same page size
35: * @param widthOffset Length in X-coord, that describes length of possible pages
36: * @param visibleRect indicates the real position and size on the screen
37: * intersection. */
38: +(id) nodeWithLayers:(NSArray *)layers pageSize:(CGSize)pageSize pagesOffset: (int) pOffset visibleRect: (CGRect)rect;
39:
40: /** Inits scrollLayer with given pages & width offset.
41: * @param layers NSArray of CCLayers, that will be used as pages.
42: * @param pageSize indicates the size of every page, now this version requires each page
43: * share the same page size
44: * @param pagesOffset Length in X-coord, that describes length of possible pages
45: * @param visibleRect indicates the real position and size on the screen
46: * intersection. */
47: -(id) initWithLayers:(NSArray *)layers pageSize:(CGSize)pageSize pagesOffset: (int) pOffset visibleRect: (CGRect)rect;
48:
49: @end
.h file
1: @implementation FGScrollLayer
2:
3: @synthesize pagesOffset = pagesOffset_;
4: @synthesize pageHeight = pageHeight_;
5: @synthesize pageWidth = pageWidth_;
6:
7: +(id) nodeWithLayers:(NSArray *)layers pageSize:(CGSize)pageSize pagesOffset:(int)pOffset visibleRect:(CGRect)rect{
8: return [[[self alloc] initWithLayers: layers pageSize:pageSize pagesOffset:pOffset visibleRect:rect] autorelease];
9: }
10:
11: -(id) initWithLayers:(NSArray *)layers pageSize:(CGSize)pageSize pagesOffset:(int)pOffset visibleRect:(CGRect)rect{
12: if ( (self = [super init]) )
13: {
14: NSAssert([layers count], @"FGScrollLayer#initWithLayers:widthOffset: you must provide at least one layer!");
15:
16: // Enable Touches/Mouse.
17: #ifdef __IPHONE_OS_VERSION_MAX_ALLOWED
18: self.isTouchEnabled = YES;
19: #endif
20:
21: // Save offset.
22: self.pagesOffset = pOffset;
23:
24: // Save array of layers.
25: layers_ = [[NSMutableArray alloc] initWithArray:layers copyItems:NO];
26:
27: // Save pages size for later calculation
28: pageHeight_ = pageSize.height;
29: pageWidth_ = pageSize.width;
30: maxVerticalPos_ = pageHeight_ * [layers_ count] - rect.size.height + 5;
31:
32: realBound = rect;
33:
34: [self updatePages];
35: }
36: return self;
37: }
38:
39: - (void) dealloc
40: {
41: self.delegate = nil;
42:
43: [layers_ release];
44: layers_ = nil;
45:
46: [super dealloc];
47: }
48:
49: - (void) updatePages
50: {
51: // Loop through the array and add the screens if needed.
52: int i = 0;
53: for (CCLayer *l in layers_)
54: {
55: l.position = ccp(realBound.origin.x, realBound.origin.y + (realBound.size.height - i * (pageHeight_ - self.pagesOffset)));
56: if (!l.parent)
57: [self addChild:l];
58: i++;
59: }
60: }
.m file
Note: the realBound variable indicates the real position and size of the scroll view in the screen. And in the method updatePages, we calculate the real position for each item according to the realBound. So here we can simply leave scroll view layer with default position(0,0).
Step 2: Write the touch handler
1: @protocol FGScrollLayerDelegate
2:
3: @optional
4:
5: /** Called when scroll layer begins scrolling.
6: * Usefull to cancel CCTouchDispatcher standardDelegates.
7: */
8: - (void) scrollLayerScrollingStarted:(FGScrollLayer *) sender;
9:
10: /** Called at the end of moveToPage:
11: */
12: - (void) scrollLayer: (FGScrollLayer *) sender scrolledToPageNumber: (int) page;
13:
14: @end
Put above code in the .h file outside of the definition of class FGScrollLayer. We need a delegate to help us handle all touch events. Besides, we need to decide whether we need to steal touch events or sending those events to items to handle.
After defining a delegate, put below code into the definition of class FGScrollLayer.
1: NSObject <FGScrollLayerDelegate> *delegate_;
2:
3: // The screen coord of initial point the user starts their swipe.
4: CGFloat startSwipe_;
5:
6: // The coord of initial position the user starts theri swipe.
7: CGFloat startSwipeLayerPos_;
8:
9: // For what distance user must slide finger to start scrolling menu.
10: CGFloat minimumTouchLengthToSlide_;
11:
12: // Internal state of scrollLayer (scrolling or idle).
13: int state_;
14:
15: BOOL stealTouches_;
16:
17: #ifdef __IPHONE_OS_VERSION_MAX_ALLOWED
18: // Holds the touch that started the scroll
19: UITouch *scrollTouch_;
20: #endif
Also add some properties in the .h file. See below:
1: @property (readwrite, assign) NSObject <FGScrollLayerDelegate> *delegate;
2:
3: #pragma mark Scroll Config Properties
4:
5: /** Calibration property. Minimum moving touch length that is enough
6: * to cancel menu items and start scrolling a layer.
7: */
8: @property(readwrite, assign) CGFloat minimumTouchLengthToSlide;
9:
10: /** If YES - when starting scrolling FGScrollLayer will claim touches, that are
11: * already claimed by others targetedTouchDelegates by calling CCTouchDispatcher#touchesCancelled
12: * Usefull to have ability to scroll with touch above menus in pages.
13: * If NO - scrolling will start, but no touches will be cancelled.
14: * Default is YES.
15: */
16: @property(readwrite) BOOL stealTouches;
Now turn to the implementation side. Put those code below into initialization method in the .m file.
1: self.stealTouches = YES;
2:
3: // Set default minimum touch length to scroll.
4: self.minimumTouchLengthToSlide = 30.0f;
And add several methods to handle touch events.
1: enum
2: {
3: kFGScrollLayerStateIdle,
4: kFGScrollLayerStateSliding,
5: };
6:
7: #ifdef __IPHONE_OS_VERSION_MAX_ALLOWED
8: @interface CCTouchDispatcher (targetedHandlersGetter)
9:
10: - (id<NSFastEnumeration>) targetedHandlers;
11:
12: @end
13:
14: @implementation CCTouchDispatcher (targetedHandlersGetter)
15:
16: - (id<NSFastEnumeration>) targetedHandlers
17: {
18: return targetedHandlers;
19: }
20:
21: @end
22: #endif
23: #ifdef __IPHONE_OS_VERSION_MAX_ALLOWED
24:
25: /** Register with more priority than CCMenu's but don't swallow touches. */
26: -(void) registerWithTouchDispatcher
27: {
28: #if COCOS2D_VERSION >= 0x00020000
29: CCTouchDispatcher *dispatcher = [[CCDirector sharedDirector] touchDispatcher];
30: int priority = kCCMenuHandlerPriority - 1;
31: #else
32: CCTouchDispatcher *dispatcher = [CCTouchDispatcher sharedDispatcher];
33: int priority = kCCMenuTouchPriority - 1;
34: #endif
35:
36: [dispatcher addTargetedDelegate:self priority: priority swallowsTouches:NO];
37: }
38:
39: /** Hackish stuff - stole touches from other CCTouchDispatcher targeted delegates.
40: Used to claim touch without receiving ccTouchBegan. */
41: - (void) claimTouch: (UITouch *) aTouch
42: {
43: #if COCOS2D_VERSION >= 0x00020000
44: CCTouchDispatcher *dispatcher = [[CCDirector sharedDirector] touchDispatcher];
45: #else
46: CCTouchDispatcher *dispatcher = [CCTouchDispatcher sharedDispatcher];
47: #endif
48:
49: // Enumerate through all targeted handlers.
50: for ( CCTargetedTouchHandler *handler in [dispatcher targetedHandlers] )
51: {
52: // Only our handler should claim the touch.
53: if (handler.delegate == self)
54: {
55: if (![handler.claimedTouches containsObject: aTouch])
56: {
57: [handler.claimedTouches addObject: aTouch];
58: }
59: }
60: else
61: {
62: // Steal touch from other targeted delegates, if they claimed it.
63: if ([handler.claimedTouches containsObject: aTouch])
64: {
65: if ([handler.delegate respondsToSelector:@selector(ccTouchCancelled:withEvent:)])
66: {
67: [handler.delegate ccTouchCancelled: aTouch withEvent: nil];
68: }
69: [handler.claimedTouches removeObject: aTouch];
70: }
71: }
72: }
73: }
74:
75: -(void)ccTouchCancelled:(UITouch *)touch withEvent:(UIEvent *)event
76: {
77: if( scrollTouch_ == touch ) {
78: scrollTouch_ = nil;
79: }
80: }
81:
82: -(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event
83: {
84: if( scrollTouch_ == nil ) {
85: scrollTouch_ = touch;
86: } else {
87: return NO;
88: }
89:
90: CGPoint touchPoint = [touch locationInView:[touch view]];
91: touchPoint = [[CCDirector sharedDirector] convertToGL:touchPoint];
92:
93: startSwipe_ = touchPoint.y;
94: startSwipeLayerPos_ = [self position].y;
95: state_ = kFGScrollLayerStateIdle;
96: return YES;
97: }
98:
99: - (void)ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event
100: {
101: if( scrollTouch_ != touch ) {
102: return;
103: }
104:
105: CGPoint touchPoint = [touch locationInView:[touch view]];
106: touchPoint = [[CCDirector sharedDirector] convertToGL:touchPoint];
107:
108:
109: // If finger is dragged for more distance then minimum - start sliding and cancel pressed buttons.
110: // Of course only if we not already in sliding mode
111: if ( (state_ != kFGScrollLayerStateSliding)
112: && (fabsf(touchPoint.y-startSwipe_) >= self.minimumTouchLengthToSlide) )
113: {
114: state_ = kFGScrollLayerStateSliding;
115:
116: // Avoid jerk after state change.
117: startSwipe_ = touchPoint.y;
118: startSwipeLayerPos_ = [self position].y;
119: previousTouchPointY = touchPoint.y;
120:
121: if (self.stealTouches)
122: {
123: [self claimTouch: touch];
124: }
125:
126: if ([self.delegate respondsToSelector:@selector(scrollLayerScrollingStarted:)])
127: {
128: [self.delegate scrollLayerScrollingStarted: self];
129: }
130: }
131:
132: if (state_ == kFGScrollLayerStateSliding)
133: {
134: CGFloat desiredY = startSwipeLayerPos_ + touchPoint.y - startSwipe_;
135: [self setPosition:ccp(0, desiredY)];
136: }
137: }
138: #endif
Basically, I borrowed these touch events handlers from CCScrollLayer, and I modified it a little bit for our own use. The structure is derived from CCScrollLayer, including the stealing touch events idea. Here we use variables startWipe_ and minimumTouchLengthToSlide_ to help us check whether user are sliding their fingers or simply touching the screen. If it is a sliding, we steal it; otherwise we send it to behind layers, e.g. items.
Step 4: Decides which items should be visible
With handling touch events, our scroll view is able to move accordingly. However, we need to restrict scroll view to some specific area, and that is the reason why we pass realBound as an initializing parameter. So, we need to update visibility of each item when moving scroll layer.
Put these two methods in the implementation file(.m file).
1: /**
2: * According to current position, decide which pages are visible
3: */
4: -(void)updatePagesAvailability{
5: CGPoint currentPos = [self position];
6: if (currentPos.y > 0) {
7: int visibleBoundUp = currentPos.y / pageHeight_;
8: visibleBoundUp = MIN([layers_ count], visibleBoundUp);
9: for (int i = 0; i < visibleBoundUp; i++) {
10: [[layers_ objectAtIndex:i] setVisible:NO];
11: }
12: if (visibleBoundUp < [layers_ count]) {
13: int visibleBoundDown = (currentPos.y + realBound.size.height) / pageHeight_;
14: visibleBoundDown = MIN([layers_ count] - 1, visibleBoundDown);
15: for (int i = visibleBoundUp; i <= visibleBoundDown; i++) {
16: [[layers_ objectAtIndex:i] setVisible:YES];
17: }
18: if (visibleBoundDown < [layers_ count] - 1) {
19: for (int i = visibleBoundDown + 1; i <= [layers_ count] - 1; i++) {
20: [[layers_ objectAtIndex:i] setVisible:NO];
21: }
22: }
23: }
24: }
25: else if (currentPos.y <= 0){
26: CGFloat gapY = -currentPos.y;
27: int visibleBound = (realBound.size.height - gapY) / pageHeight_;
28: // index visibleBound itself should be invisible
29: if (visibleBound < 0) {
30: for (int i = 0; i < [layers_ count]; i++) {
31: [[layers_ objectAtIndex:i] setVisible:NO];
32: }
33: return;
34: }
35: visibleBound = MIN([layers_ count] - 1, visibleBound);
36: for (int i = 0; i <= visibleBound; i++) {
37: [[layers_ objectAtIndex:i] setVisible:YES];
38: }
39: for (int i = visibleBound + 1; i < [layers_ count]; i++) {
40: [[layers_ objectAtIndex:i] setVisible:NO];
41: }
42: }
43: }
44:
45: -(void)setPosition:(CGPoint)position{
46: [super setPosition:position];
47: [self updatePagesAvailability];
48: }
The first method here is to calculate each item’s visibility, and the second method here is to override setPosition to call update method. Besides, add one line to the updatePages method.
1: [self updatePagesAvailability];
Step 5: Apply a simple sliding algorithm.
Okay, until now, we have finished the basic work of implementing a scroll view. Yet it is not a good one, since users’ sliding feelings are not fluent enough. Here, I have a very simple sliding algorithm. I know this is not a perfect solution, and this algorithm is far from Apple’s elegant sliding implementation. I just present it for lazy persons who just want it work. My simple idea is to calculate and store the finger moving speed. When users release their fingers, calculate the momentum according to their before finger moving speed and apply a following inertia effect. Below is how exactly I did it.
1: // these two variables are to make a sliding effect on scroll view
2: static CGFloat previousTouchPointY = -1;
3: static CGFloat moveSpeed = 0;
4: - (void)ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event
5: {
6: if( scrollTouch_ != touch ) {
7: return;
8: }
9:
10: CGPoint touchPoint = [touch locationInView:[touch view]];
11: touchPoint = [[CCDirector sharedDirector] convertToGL:touchPoint];
12:
13:
14: // If finger is dragged for more distance then minimum - start sliding and cancel pressed buttons.
15: // Of course only if we not already in sliding mode
16: if ( (state_ != kFGScrollLayerStateSliding)
17: && (fabsf(touchPoint.y-startSwipe_) >= self.minimumTouchLengthToSlide) )
18: {
19: state_ = kFGScrollLayerStateSliding;
20:
21: // Avoid jerk after state change.
22: startSwipe_ = touchPoint.y;
23: startSwipeLayerPos_ = [self position].y;
24: previousTouchPointY = touchPoint.y;
25:
26: if (self.stealTouches)
27: {
28: [self claimTouch: touch];
29: }
30:
31: if ([self.delegate respondsToSelector:@selector(scrollLayerScrollingStarted:)])
32: {
33: [self.delegate scrollLayerScrollingStarted: self];
34: }
35: }
36:
37: if (state_ == kFGScrollLayerStateSliding)
38: {
39: CGFloat desiredY = startSwipeLayerPos_ + touchPoint.y - startSwipe_;
40: [self setPosition:ccp(0, desiredY)];
41:
42: // enable scroll bar to be visible
43: [scrollBar setVisible:YES];
44: [scrollBlock setVisible:YES];
45:
46: // update scrolling effect variables
47: moveSpeed = touchPoint.y - previousTouchPointY;
48: previousTouchPointY = touchPoint.y;
49: }
50: }
51:
52: /**
53: * After touching, generate an inertia effect.
54: */
55: - (void)moveToDesiredPos:(CGFloat)desiredY{
56: CCAction* slidingAction = nil;
57: if (desiredY > maxVerticalPos_) {
58: slidingAction = [CCSequence actions:[CCMoveTo actionWithDuration:0.10 position:ccp([self position].x, desiredY)], [CCMoveTo actionWithDuration:0.15 position:ccp([self position].x, maxVerticalPos_)], nil];
59: }
60: else if (desiredY < 0){
61: slidingAction = [CCSequence actions:[CCMoveTo actionWithDuration:0.10 position:ccp([self position].x, desiredY)],[CCMoveTo actionWithDuration:0.15 position:ccp([self position].x, 0)], nil];
62: }
63: else{
64: CGFloat interPosY = (desiredY - [self position].y) * 0.7 + [self position].y;
65: slidingAction = [CCSequence actions:[CCMoveTo actionWithDuration:0.15 position:ccp([self position].x, interPosY)],[CCMoveTo actionWithDuration:0.3 position:ccp([self position].x, desiredY)], nil];
66: }
67: [self runAction:slidingAction];
68: }
69:
70: - (void)ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event
71: {
72: [scrollBar setVisible:NO];
73: [scrollBlock setVisible:NO];
74:
75: if( scrollTouch_ != touch )
76: return;
77: scrollTouch_ = nil;
78:
79: if (ABS(moveSpeed) > 10) {
80: CGFloat desiredDesY = [self position].y + moveSpeed * 5;
81: [self moveToDesiredPos:desiredDesY];
82: }
83: else{
84: if ([self position].y > maxVerticalPos_) {
85: [self runAction:[CCMoveTo actionWithDuration:0.3 position:ccp([self position].x, maxVerticalPos_)]];
86: }else if ([self position].y < 0){
87: [self runAction:[CCMoveTo actionWithDuration:0.3 position:ccp([self position].x, 0)]];
88: }
89: }
90:
91: // restore scrolling effect variables to default value
92: moveSpeed = 0;
93: previousTouchPointY = -1;
94: }
Step 6: What about the appearance of scroll view layer?
Everyone’s scroll view should look different, we need to add some decorative images to our viewer, e.g. we’d like to put our view into a frame, or we need a slider bar to indicate the current position of the whole list. What about those needs I just mentioned? Okay, here is the solution. You may create a class that inherit from this scroll layer, and specify whatever variables and images that you need to decorate your viewer. Here I provide my simple version. I wrote these variables into scroll layer class because they are very common for a standard scroll view, if you don’t need them, you may delete them or ignore them in your derived class.
Put below code in the definition file(.h file).
1: /*Decoration and slide bars*/
2: // Scroll bars on the right
3: CCSprite* scrollBar;
4: CGFloat scrollBarPosY;
5:
6: // Scroll block that indicates the current position in whole scorll view content
7: CCSprite* scrollBlock;
8: CGFloat scrollBlockUpperBound;
9: CGFloat scrollBlockLowerBound;
10:
11: // Decoration
12: // Holds position to maintain their position fixed even in setPosition
13: CCSprite* upperBound;
14: CGFloat upperBoundPosY;
15: CCSprite* lowerBound;
16: CGFloat lowerBoundPosY;
And add those code in setPosition method in .m file.
1: CGFloat scrollBlockDesiredY = scrollBlockUpperBound - (scrollBlockUpperBound - scrollBlockLowerBound) * position.y / maxVerticalPos_;
2: if (scrollBlockDesiredY > scrollBlockUpperBound) {
3: scrollBlockDesiredY = scrollBlockUpperBound;
4: }else if (scrollBlockDesiredY < scrollBlockLowerBound){
5: scrollBlockDesiredY = scrollBlockLowerBound;
6: }
7: [scrollBlock setPosition:ccp([scrollBlock position].x, scrollBlockDesiredY - position.y)];
8: [lowerBound setPosition:ccp([lowerBound position].x, lowerBoundPosY - position.y)];
9: [upperBound setPosition:ccp([upperBound position].x, upperBoundPosY - position.y)];
10: [scrollBar setPosition:ccp([scrollBar position].x, scrollBarPosY - position.y)];
Add below code in ccTouchEnded and ccTouchCanceled methods.
1: [scrollBar setVisible:NO];
2: [scrollBlock setVisible:NO];
Add below code in ccTouchMoved method.
1: // enable scroll bar to be visible
2: [scrollBar setVisible:YES];
3: [scrollBlock setVisible:YES];
Now the only left thing is to provide proper images and values to the corresponding variables in your initialization method.
Finished Version
After reading above messy code fragments, you may want to download a complete version. Click here!
Conclusion
Scroll view is not a simple gadget that every game engine will provide. Besides, every game needs its own style scroll view. Therefore it is hard to offer an universally suitable scroll view. This blog offers a provides an adapter class for implementing your own scroll view. Implementing a scroll view by cocos2d is not a easy task, at least it is a tedious task. And it requires a lot of effort and carefulness in adjusting the relative position and calculating the visibility of each item. You may derive your own class from this layer, and this layer already help you achieve a lot of tedious work, e.g. touch events handling and scrolling. What left to do is to provide decorative images to make it look nice and elegant.