REVISED: How to create a star rater with Cappuccino
After writing How to create a star rater with Cappucccino it became obvious that there was more to explain and lots of room to improve the code, especially in conciseness.
Prelude
With some help from the friendly 280North folks, here is a revised version of the Starrater. We won’t beat the 87 lines of Komodo Medias, Example two of CSS Star Rating Part Deux, but get pretty close for the same functionally and build a reusable, pluggable control, we could easily use with Atlas.
The revised version uses CoreGraphics components instead of the layered CPView solutions I used. You will also find the use of the CPNotificationCenter to make sure that all images have been loaded before we try to draw on our canvas. This is important as we cannot draw images that haven’t been loaded yet.
Obj-J crashcourse
If you are new to Objective-J and have never seen Objective-C or Smalltalk, you might have some trouble understanding the code.
If you come from a Java,C++,… background you will be used to statements like type method(type argument){ ... }. And invoke it like object.method(arg). Objective-J (and Objective-C) are not that different. The first statement looks like - (type) method:(type)agrument { ... } and its invocation is [object method:arg]. The type id is the identity type and means the object will return itself or an instance of itself, is a unspecific type. Think of it as a placeholder for an arbitrary type. (somewhat like void* in C) BOOL values are YES and NO and getters are usually not prefixed with get.
Sidenote: The invocation is interesting: it is not really invoking the method with an argument. It is sending a message to the object asking for the method to be invoked with an argument.
The StarRatingControl
Instead of subclassing CPView we will more appropriately subclass CPControl for our StarRatingControl. This has the advantage that we can take advantage of all the build in functionality of controls like the automatic event dispatching and controlling the redraw logic, which triggers drawRect when ever the component needs to be redrawn. To trigger this programmatically we send YES to the setNeedsDisplay: method. So without much more ado, here is the code for the StarRatingControl.
@import <AppKit/CPControl.j>
@import <AppKit/CPImage.j>
var starEmpty = [[CPImage alloc] initWithContentsOfFile: "Resources/StarRater/empty.gif" size: CPSizeMake(25, 25)],
starSet = [[CPImage alloc] initWithContentsOfFile: "Resources/StarRater/set.gif" size: CPSizeMake(25, 25)],
starActive = [[CPImage alloc] initWithContentsOfFile: "Resources/StarRater/active.gif" size: CPSizeMake(25, 25)];
@implementation StarRatingControl : CPControl
{
int numberOfStars @accessors;
int activeValue;
BOOL isActive;
}
- (id)initWithFrame:(CGRect)aFrame
{
if (self = [super initWithFrame:aFrame])
{
numberOfStars = 6;
self._DOMElement.style.cursor = "pointer"; // hack until cappuccino has pointer support
}
return self;
}
- (void)viewWillMoveToWindow:(CPWindow)aWindow
{
[aWindow setAcceptsMouseMovedEvents:YES];
}
// This will be called whenever we setNeedsDisplay to YES
- (void)drawRect:(CGRect)aRect
{
if([starEmpty loadStatus] != CPImageLoadStatusCompleted)
return [[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(imageDidLoad:) name:CPImageDidLoadNotification object:starEmpty];
if([starSet loadStatus] != CPImageLoadStatusCompleted)
return [[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(imageDidLoad:) name:CPImageDidLoadNotification object:starSet];
if([starActive loadStatus] != CPImageLoadStatusCompleted)
return [[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(imageDidLoad:) name:CPImageDidLoadNotification object:starActive];
var context = [[CPGraphicsContext currentContext] graphicsPort],
bounds = [self bounds],
starWidth = bounds.size.width / numberOfStars,
starHeight = bounds.size.height,
value = [self intValue];
for (var i=0; i<numberOfStars; i++)
{
if (isActive && activeValue > i)
CGContextDrawImage(context, CGRectMake(starWidth*i, 0, starWidth, starHeight), starActive);
else
{
if (value > i)
CGContextDrawImage(context, CGRectMake(starWidth*i, 0, starWidth, starHeight), starSet);
else if (value <= i)
CGContextDrawImage(context, CGRectMake(starWidth*i, 0, starWidth, starHeight), starEmpty);
}
}
}
-(void)imageDidLoad:(CPNotification)aNotification
{
[[CPNotificationCenter defaultCenter] removeObserver:self name:CPImageDidLoadNotification object:[aNotification object]];
[self setNeedsDisplay:YES];
}
- (void)mouseEntered:(CPEvent)anEvent
{
isActive = YES;
[self setNeedsDisplay:YES];
}
- (void)mouseExited:(CPEvent)anEvent
{
isActive = NO;
[self setNeedsDisplay:YES];
}
- (void)mouseMoved:(CPEvent)anEvent
{
var offset = [self convertPoint:[anEvent locationInWindow] fromView:nil].x,
bounds = [self bounds],
starWidth = bounds.size.width / numberOfStars;
activeValue = offset < 5 ? 0 : CEIL(offset/starWidth);
[self setNeedsDisplay:YES];
}
- (void)mouseDown:(CPEvent)anEvent
{
[self setIntValue:activeValue];
activeValue = 0;
[super mouseDown:anEvent];
[self setNeedsDisplay:YES]; // this is no more needed if using git head.
}
- (void)sizeToFit
{
[self setFrameSize:CGSizeMake(numberOfStars*25, 25)];
}
@end
At this point we have all the functionally the rater we based this one on has. The source is still very verbose and line count could be further broken down by using the ternary operator more. As every cappuccino app will be steamed and packed before shipping, we can neglect this part and pay more attention to readability. This is where cappuccino shines, you can write very clear code that is easy to maintain and does not need to include many quirks.
Extending the StarRaterControl
Wouldn’t it be nice to have an indicator which responds to the selected stars? Like the one you saw in the Atlas demo. When you saw Francisco Tolmasky dragging a CPSlider and a CPTextTield onto window and connecting both, it could as well have been our StarRatingControl. You could simply drag it and a CPTextView onto the window and just connect them. For the time being we will have to write this ourself.
I decided to create a new StarRatingView that subclasses the CPView to houses both, the StarRatingControl and the CPTextField and expose both to be accessed as readonly using the @accessor, which synthesizes getters for both. This has the advantage that I can just use the new view and plug it where I need it. The idea is to reduce writing the same code over and over again, if you want to use a StarRatingControl with a CPTextView for more then a few times. As Ross Boucher from 280North noted, by using the view you restrict yourself to set the text size, color and placement in the view and loose some flexibility. You won’t need this anymore when Atlas arrived as you won’t have to write that code anymore anyway. Sadly Atlas won’t be here for some time.
@import <AppKit/CPView.j>
@import "StarRatingControl.j"
var values = [ @"No selection",
@"Failed",
@"Not good",
@"Average",
@"Good",
@"Very good",
@"Excellent" ];
@implementation StarRatingView : CPView
{
StarRatingControl rater @accessors(readonly);
CPTextField indicator @accessors(readonly);
}
- (void)initWithFrame:(CGRect)aFrame
{
if (self = [super initWithFrame:aFrame])
{
rater = [[StarRatingControl alloc] initWithFrame:CGRectMakeZero()];
[rater setNumberOfStars:6];
[rater sizeToFit];
[rater setTarget:self];
[rater setAction:@selector(starClick:)];
indicator = [[CPTextField alloc] initWithFrame:CGRectMakeZero()];
[indicator setPlaceholderString:values[0]];
[indicator sizeToFit];
var s_bounds = [self bounds],
r_bounds = [rater bounds],
i_bounds = [indicator bounds],
origin = CGPointMake(r_bounds.size.width + 5, (r_bounds.size.height - i_bounds.size.height) / 2.0),
size = CGSizeMake(s_bounds.size.width - r_bounds.size.width - 5, i_bounds.size.height);
[indicator setFrameOrigin:origin];
[indicator setFrameSize:size];
[indicator setAutoresizingMask: CPViewWidthSizable];
[self addSubview:rater];
[self addSubview:indicator];
}
return self;
}
- (void)starClick:(id)sender
{
[indicator setStringValue:values[[sender intValue]]];
}
@end
Building the Demo Application
Now it should be straight forward how to construct a cappuccino application. Using the Cappuccino Starter Package, we will alter the AppControler.j to display our created control.
@import <Foundation/CPObject.j>
@import "StarRatingView.j"
@implementation AppController : CPObject
{
}
- (void)applicationDidFinishLaunching:(CPNotification)aNotification
{
var theWindow = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask],
contentView = [theWindow contentView];
var rater = [[StarRatingView alloc] initWithFrame:CGRectMake(0,0,300,25)];
[rater setAutoresizingMask:CPViewMinXMargin | CPViewMaxXMargin | CPViewMinYMargin | CPViewMaxYMargin];
[rater setFrameOrigin:CGPointMake((CGRectGetWidth([contentView bounds])
- CGRectGetWidth([rater frame])) / 2.0,
(CGRectGetHeight([contentView bounds])
- CGRectGetHeight([rater frame])) / 2.0)];
[contentView addSubview:rater];
[theWindow orderFront:self];
}
@end
Demo and Download
Here is the non-iframed version, you can also browse, download the complete source for this tutorial from github.
Conclusion
Once you get used to Objective-J and Cappuccino, writing reusable components is becoming easy. Once the documentation improves, Objective-J and Cappuccino could become serious contender for the RIA market. There are some holes in Cappuccino, but as 280Slides showed, it can already be used to develop high class RIA now! For now the IRC channel and the mailinglist are the most valuable choices for resources. Cappuccinos source is, though with very few comments, pretty good readable.
Subscribe
Digg
del.icio.us
Twitter
StumbleUpon
Reddit