How to create a star rater with Cappuccino

Update: REVISED: How to create a star rater with Cappuccino

After the Atlas demo, interest in Cappuccino has picked up again. In this tutorial I’d like to how to create a star rater with Cappuccino. A star rater is a control/widget that lets the user choose a visual rating using stars. Komodo Media has shown techniques to do this with css in 2005, 2006 and 2007.

StarRater

The Approach

We will be rebuilding Example 2 of Part Deux. The main idea will be to set two CPViews into a parent CPView. One to control the current selected amount of stars and a second for for hover effect. If you don’t have any experience with Objective-J, I recommend Learning Objective-J, though you should still be able to follow along.

Precosiderations

Cappuccino does not support sprits, therefor we have to split the background-image from Example 2 into tree separate 25x25 images.

empty empty.gif . set set.gif . active active.gif .

Building the Control

When writing Cappuccino controls/widgets, it is often useful to start by subclassing CPView and start from there. Our control will feature the actual rater as well as an indicator, that shows the current selection state.

The Basic StarRater

The StarRater displays a set of empty stars and allows to select the amount of stars with the mouse pointer.

@import <Foundation/CPObject.j>

// will hold our images
var starEmpty, starSet, starActive;

@implementation StarRater : CPView
{
  int _value;          // the number of selected stars
  int _hoverStars;     // the number of stars where the mouse hovers
  CPView _starFrame;   // displays the selected stars
  CPView _hoverFrame;  // displays the stars when the mouse hovers over the control
  id _delegate;        // a callback to update when the stars changed
}
+ (void)initialize
{
  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)];
}
- (id)initWithFrame:(CPRect)frame
{
  self = [super initWithFrame: frame];
  // FIXME: set the mousepointer
  self._DOMElement.style.cursor = "pointer";
  // set our background to a pattern of the empty star image
  [self setBackgroundColor:[CPColor colorWithPatternImage:starEmpty]];

  // set the initial values for our control
  _value = 0;
  _hoverStars = 0;

  // construct the Frames
  _starFrame = [[CPView alloc] initWithFrame:[self bounds]];
  [_starFrame setBackgroundColor:[CPColor colorWithPatternImage:starSet]];

  _hoverFrame = [[CPView alloc] initWithFrame:[self bounds]];
  [_hoverFrame setBackgroundColor:[CPColor colorWithPatternImage:starActive]];

  // set the size star and hover frame. (both to 0 width)
  [self _redraw];
  [self _redrawHover];

  [self addSubview: _starFrame];
  [self addSubview: _hoverFrame];
  return self;
}

// setter and getter for the delegate
- (void)setDelegate:(id)aDelegate
{
  if (_delegate == aDelegate)
    return;
  _delegate = aDelegate;
  [self _update];
}

- (id)delegate
{
  return _delegate;
}

// setter and getter for the value
- (void)setValue:(int)value
{
  if(_value == value)
    return;
  _value = value;
  // if the value was set,
  // we want to update the delegate a notification
  // and redraw our frames accordingly.
  [self _update];
  [self _redraw];
}

- (int)intValue
{
  return _value;
}

// inform the delegate about the current value.
- (void)_update
{
  if(_delegate && [_delegate respondsToSelector:@selector(updateWithIntValue:)])
    [_delegate updateWithIntValue:_value];
}

// notify the delegate about temporary (hover) changes
- (void)_updateTemporary
{
  if(_delegate && [_delegate respondsToSelector:@selector(updateTemporaryWithIntValue:)])
    [_delegate updateTemporaryWithIntValue:_hoverStars];
}

// redraw our child frames.
- (void)_redraw
{
  [_starFrame setFrameSize:CPSizeMake(_value * 25, 25)];
}

- (void)_redrawHover
{
  [_hoverFrame setFrameSize:CPSizeMake(_hoverStars * 25, 25)];
}

// mouse movement handler
- (void)mouseDown:(CPEvent)anEvent
{
  // compute the offset relative to this frame
  var offset = [self convertPoint:[anEvent locationInWindow] fromView:nil].x
  // set a left threshold. If we click less then 5px, we won't select a star.
  if(offset < 5) [self setValue:0];
  // otherwise we set our value
  else [self setValue:Math.ceil(offset/25)];
  // setting hoverStars to 0 and redrawing them gives
  // a visual feedback about the change.
  _hoverStars = 0;
  [self _redraw];
  [self _redrawHover];
}

// when the mouse enters or left the view,
// we want to reset the visual hover aid.
- (void)mouseEntered:(CPEvent)anEvent
{
  _hoverStars = 0;
  [self _update];
  [self _redrawHover];
}
- (void)mouseExited:(CPEvent)anEvent
{
  _hoverStars = 0;
  [self _update];
  [self _redrawHover];
}

// on mouse move, we construct a visual hovering effect.     
- (void)mouseMoved:(CPEvent)anEvent
{
  var offset = [self convertPoint:[anEvent locationInWindow] fromView:nil].x
  if(offset < 5) starValue = 0;
  else starValue = Math.ceil(offset/25);
  if(starValue == _hoverStars) return;
  _hoverStars = starValue;
  [self _updateTemporary];
  [self _redrawHover];
}
@end
.

This gives us a pretty star rater, but it doesn’t show what we selected. As we want to display a message, we will subclass the CPTextField and implement the updateWithIntValue: and updateTemporaryWithIntValue: methods, which will be called from our StarRater.

The Indicator Label

Displays the current selected value to give the star rating a certain meaning and assist in the selection.

@import <Foundation/CPObject.j>

@implementation ActiveTextField : CPTextField
{
  var _values; 
}
- (id)initWithFrame:(CPRect)aFrame
{
  self = [super initWithFrame:aFrame];
  // initialize the values we want to display
  _values = [ @"No selection",
              @"Failed",
              @"Not good",
              @"Average",
              @"Good",
              @"Very good",
              @"Excellent" ];
  return self;
}
- (void) updateTemporaryWithIntValue:(int)value
{
  [self updateWithIntValue:value];
}
- (void) updateWithIntValue:(int)value
{
  [self setStringValue:_values[value]];  
}
@end
.

To wrap it all up we put our custom control into a handy CPView.

Unification View

Having two separate controls, who depend on each other would cause us to write quite some boilerplate every time we want to use them. The solution is to create a control that unifies both.

@import <Foundation/CPObject.j>
@import "StarRater.j"
@import "ActiveTextField.j"

@implementation StarRaterView : CPView
{
  CPView _rater;
}
- (id)initWithFrame:(CPRect)aFrame
{
  self = [super initWithFrame:aFrame];
  // setup the rater and the indicator
  _rater = [[StarRater alloc] initWithFrame:CGRectMake(0, 0, 6 * 25, 25)];
  var indicator  = [[ActiveTextField alloc] initWithFrame:CGRectMakeZero()];
  // let the indicator compute it's size.
  [indicator sizeToFit];
  // get the height of the indicator to vertical align it with the rater
  var height = CGRectGetHeight([indicator frame]);
  [indicator setFrameOrigin:CGPointMake(CGRectGetMaxX([_rater frame]) + 10, (25-height)/2.0)];
  // set the width of the indicator so that it can hold all labels.
  [indicator setFrameSize:CGSizeMake(100,height)];
  [_rater setDelegate:indicator];
  [self addSubview:_rater];
  [self addSubview:indicator];
  return self;
}
@end
.

Displaying our Star Rater

Finally we rewrite the applicationDidFinishLaunching: in the AppController.j from Cappuccino Starter Package to test our new Star Rater Control.

- (void)applicationDidFinishLaunching:(CPNotification)aNotification
{
  var theWindow = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask],
    contentView = [theWindow contentView];

  // capture Mouse move events
  [theWindow setAcceptsMouseMovedEvents:YES];

  // use our custom control
  var rater = [[StarRaterView alloc] initWithFrame:CGRectMakeZero()];
  [rater setFrameSize:CGSizeMake(250,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)];
  // add our rater to the window
  [contentView addSubview:rater];
  [theWindow orderFront:self];
}
.

Demo and Download

Here is the non-iframed version and you can also download the source files.

Conclusion

Developing with Cappuccino is not that easy if you have no experience with Cocoa and obj-c as knowledge with these two gets you started much faster. Cappuccinos greatest challenge is it’s weak documentation and makes the learning-curve of Cappuccino and obj-j very steep, especially to those of us who are not experienced desktop-application developer. I can highly recommend the IRC channel #cappuccino on irc.freenode.net, where you will usually get your questions answered pretty quick.

I hope you got an idea on how Cappuccino applications are written. The obj-j syntax might look a little strange at first, but if you give it a try you will get used to it pretty quick.

We have written a lot of code, compared to the 87 lines the Example 2 of Part Deux took. The verbosity of obj-j is not to neglect, we have still a lot more code. Can we still justify the solution? I think we can. We created a ready to use component we can plug into any cappuccino application and it actually does a little bit more then what we set out so solve.

Discuss

This is my first time to explain some code and I would be grateful for suggestion, hints and especially comments. What did you like, what didn’t you like? What did you miss? What would you have done different?