Creating Custom List Skins for the BlackBerry PlayBook

Disclaimer: I’m fairly certain this isn’t best practice. There is a ton of stuff going on with List and CellRenderer that I don’t fully understand. I’m hoping to sit down with the QNX team at some point and figure out how this might be done correctly, but for now I’ve found a way that works so I wanted to share it for anyone who is having issues.

Skinning a List is quite a bit different than skinning something simple like a Button or a TextInput box (which I talked about in my post here). It seems like the correct way to customize the look and feel of a list would be to create a CellRenderer which gives you access to the label for the list, and then swap out the graphics by creating a skin specifically for that CellRenderer. But my particular code didn’t rely on the label field so I skipped the first step and just created a CellRendererSkin that extends UISkin and implements ICellRenderer. By implementing ICellRenderer I get access to the data methods, but as you’ll see, that created some issues.

First off, here’s the list I wanted to create. It’s got an image and a few lines of text. You can see the regular state (white) and what it looks like when it’s selected (grey). I wanted a little bit more of a custom look so I put some space between each item and drew a rounded rectangle with a border around the content.

List Screenshot

To skin a list this way, the most important method is still initializeStates(). That method has to be overridden just like if we were skinning any other component, and it’s where we call setSkinState to associate a graphic with a state. A list has basically 8 different SkinStates: SkinStates.UP, SkinStates.UP_ODD, SkinStates.DOWN, SkinStates.FOCUS, SkinStates.DISABLED, SkinStates.SELECTED, SkinStates.DOWN_SELECTED, and SkinStates.DISABLED_SELECTED. I have no idea what FOCUS does, I’m not alternating my rows so I don’t care about ODD, and I decided I could live with my DOWN, SELECTED, and DOWN_SELECTED being the same. So here’s the code for my initializeStates() method

override protected function initializeStates():void
{
     _upSkin = new Sprite();    
     setSkinState(SkinStates.UP,_upSkin);
 
     _downSkin = new Sprite();    
     setSkinState(SkinStates.DOWN,_downSkin);
     setSkinState(SkinStates.DOWN_SELECTED,_downSkin);
     setSkinState(SkinStates.SELECTED,_downSkin);
 
     SkinStates              
     showSkin(_upSkin);
}

Here’s where things get a bit messier. With normal skinning, I could just start adding graphics to my sprites and then set the skin state accordingly. But what I found was that when the initializeStates() method got called, the width and height of the component hadn’t been set yet. So when I tried to draw a rectangle that used the height/width of the component, it would look scrunched. If you know the exact dimensions you want, you can just hard-code the values. But I wanted to be able to use this on different sized lists, so I wanted those dynamic values.

What I found was that if I overrode the setState(state:String) method, I could get the values for width/height there and then draw the correct graphics depending on whatever state was being set based on the height/width of each cell.

override protected function setState(state:String):void
{
     super.setState(state);
 
     var matrix:Matrix = new Matrix();
     matrix.createGradientBox(width,height,90/180*Math.PI);
 
     if(state == SkinStates.UP)
     {                   
          _background.graphics.clear();
          _background.graphics.beginGradientFill(GradientType.LINEAR,
                    [0xffffff,0xf2f2f2,0xffffff],[1,1,1],[0,127,255],matrix);
          _background.graphics.lineStyle(2,0x221206);
          _background.graphics.drawRoundRect(20,10,width-35,height-20,7,7);
          _background.graphics.endFill();
     }
     if(state == SkinStates.DOWN ||
          state == SkinStates.DOWN_SELECTED ||
          state == SkinStates.SELECTED)
     {                        
          _background.graphics.clear();
          _background.graphics.beginGradientFill(GradientType.LINEAR,
                    [0xaaaaaa,0xcfcfcf,0xaaaaaa],[1,1,1],[0,127,255],matrix);
          _background.graphics.lineStyle(2,0x221206);
          _background.graphics.drawRoundRect(20,10,width-35,height-20,7,7);
          _background.graphics.endFill();
     }              
}

Here I ran into another odd issue. Notice that I’m not adding the graphics to the state, but rather adding them to a _background Sprite. What I found is that when I would try to draw the graphics right on the skin sprite it would either overwrite my other content when the state changed and I couldn’t get it back, or the graphics wouldn’t display at all. I’m still not entirely sure why those two things happened and I went through so many iterations that I don’t remember the code that caused it. But what I found was that if I created a background Sprite and added that to the display list first, I could alter it depending on the state and it would draw correctly. That’s also why I have a _background.graphics.clear() call because the _background Sprite is in every state, it just needs to be redrawn when the state changes.

The next step was to add everything to the cell renderer and then to clean it up when the cell renderer goes away. All of the QNX components have an onAdded() and onRemoved() method that gets called when the object is added to or removed from the stage. So I just overrode those methods and added my content.

override protected function onAdded():void
{
     super.onAdded();
     addChild(_background);
     addChild(_image);
     addChild(_name);
     addChild(_brewery);
     addChild(_beerType);
     addChild(_ratingText);
     addChild(_avgRating);
}
 
override protected function onRemoved():void
{
     super.onRemoved();
     removeChild(_background);
     removeChild(_image);
     removeChild(_name);
     removeChild(_brewery);
     removeChild(_beerType);
     removeChild(_ratingText);
     removeChild(_avgRating);
}

There’s just one final step. As you’ll see in my code below, I set up most of the properties of the labels and the image in the constructor. But I don’t set any of the dynamic data. That’s because I had a really hard time finding out when I could access the data property of the cell renderer. There isn’t any data on init(), onAdded(), or initializeStates() so trying to set the dynamic data in those methods threw an error. I could access it in the setState() method, but I found that when I tried to set it there, the list wouldn’t display the values correctly. I finally figured out that it was because of the way the list is virtualized. The setState() method doesn’t get called when you initially scroll the list because the state hasn’t changed, just the data has. So I was seeing the values repeat when I’d scroll the list and didn’t see the correct value until I clicked on it and forced setState() to be called.

The solution was just to embrace the fact that I was implementing ICellRenderer and set all of the data in the data setter method. That set the data correctly for each cell and didn’t depend on the state at all. I also had to set the width of the Label objects there so that the text wouldn’t be cut off.

public function set data(data:Object):void
{
     _beer = data;
 
     _image.setImage(data.thumb);
 
     _name.text = data.beerName;
     _name.width = width-150;
 
     _brewery.text = data.brewerName;
     _brewery.width = width-150;
 
     _beerType.text = data.styleName;
     _beerType.width = width-150;
 
     if(data.avgRating > 3)
     {
          _avgRatingFormat.color = 0x4c9d17;
     } else if (data.avgRating < 1)
     {
          _avgRatingFormat.color = 0x9d1717;
     }
 
     _avgRating.text = data.avgRating;
     _avgRating.format = _avgRatingFormat;
}

Again, I want to stress that this probably isn’t the ideal way to do this. Especially if you have a pretty basic label you could just extend CellRenderer and override the draw() and drawLabel() method to do what you want. Or, like I said above, apply a special skin to that CellRenderer that handles all of the states for the list correctly. This was the rabbit hole that I went down though, and I found it to be kind of handy because I killed all of my birds with one class. Even if it’s an ugly class. Here’s the full code.

package com.pintley.components.listClasses
{
     import flash.display.GradientType;
     import flash.display.Sprite;
     import flash.filters.DropShadowFilter;
     import flash.geom.Matrix;
     import flash.text.TextFormat;
 
     import qnx.ui.display.Image;
     import qnx.ui.listClasses.ICellRenderer;
     import qnx.ui.skins.SkinStates;
     import qnx.ui.skins.UISkin;
     import qnx.ui.text.Label;
 
     public class BeerCellRendererSkin extends UISkin implements ICellRenderer
     {
          protected var _beer:Object;
          private var _row:int;
          private var _column:int;
          private var _section:int;
          private var _index:int;    
 
          private var _yOffset:int = 12;
          private var _xOffset:int = 35;
 
          /**
           * Skins
           **/
          protected var _upSkin:Sprite;
          protected var _selectedSkin:Sprite;
          protected var _downSkin:Sprite;
          protected var _disabledSkin:Sprite;
 
          /**
           * Cell Renderer content
           **/
          // I use a background sprite because I want to
          // make sure it's the lowest layer. Then I can just
          // modify the lowest layer without overwriting the text.
          protected var _background:Sprite;
          protected var _name:Label;
          protected var _brewery:Label;
          protected var _beerType:Label;
          protected var _ratingText:Label;
          protected var _avgRating:Label;
          protected var _image:Image;
 
          /**
           * TextFormats
           **/
          protected var _nameFormat:TextFormat;
          protected var _breweryFormat:TextFormat;
          protected var _beerTypeFormat:TextFormat;
          protected var _ratingTextFormat:TextFormat;
          protected var _avgRatingFormat:TextFormat;
 
          public function BeerCellRendererSkin()
          {
               super();
 
               /**
                * TextFormats
                **/
               _nameFormat = new TextFormat();
               _nameFormat.color = 0xbd5251;
               _nameFormat.size = 16;
               _nameFormat.bold = true;
 
               _breweryFormat = new TextFormat();
               _breweryFormat.color = 0x525252;
               _breweryFormat.size = 14;
               _breweryFormat.bold = true;
 
               _beerTypeFormat = new TextFormat();
               _beerTypeFormat.color = 0x525252;
               _beerTypeFormat.size = 14;
 
               _ratingTextFormat = new TextFormat();
               _ratingTextFormat.color = 0x79523e;
               _ratingTextFormat.size = 12;
               _ratingTextFormat.bold = true;
 
               _avgRatingFormat = new TextFormat();
               _avgRatingFormat.color = 0x000000;
               _avgRatingFormat.size = 14;
               _avgRatingFormat.bold = true;
 
               /**
                * CellRenderer Content
                **/
               _image = new Image();
               _image.x = _xOffset;
               _image.y = 20;
               _image.filters = [new DropShadowFilter(3,45,0x000000,.5,4,4,.5)];
 
               _name = new Label();
               _name.x = _xOffset+80;
               _name.y = _yOffset;
               _name.format = _nameFormat;
 
               _brewery = new Label();
               _brewery.x = _xOffset+80;
               _brewery.y = _yOffset+20;
               _brewery.format = _breweryFormat;
 
               _beerType = new Label();
               _beerType.x = _xOffset+80;
               _beerType.y = _yOffset+35;
               _beerType.format = _beerTypeFormat;
 
               _ratingText = new Label();
               _ratingText.x = _xOffset+80;
               _ratingText.y = _yOffset+55;
               _ratingText.format = _ratingTextFormat;
               _ratingText.text = "AVG Rating";
 
               _avgRating = new Label();
               _avgRating.x = _xOffset + 150;
               _avgRating.y = _yOffset+54;
 
               _background = new Sprite();
          }
 
 
 
          /**
           * Getters/Setters
           **/
 
          public function get data():Object
          {
               return _beer;
          }
 
          public function set data(data:Object):void
          {
               _beer = data;
 
               // Set the text and images for the
               // label after we get data from the list.
               _image.setImage(data.thumb);
 
               _name.text = data.beerName;
               _name.width = width-150;
 
               _brewery.text = data.brewerName;
               _brewery.width = width-150;
 
               _beerType.text = data.styleName;
               _beerType.width = width-150;
 
               if(data.avgRating > 3)
               {
                    _avgRatingFormat.color = 0x4c9d17;
               } else if (data.avgRating < 1)
               {
                    _avgRatingFormat.color = 0x9d1717;
               }
 
               _avgRating.text = data.avgRating;
               _avgRating.format = _avgRatingFormat;
          }
 
          public function get index():int
          {
               return _index;
          }
 
          public function set index(value:int):void
          {
               _index = value;
          }
 
          public function get row():int
          {
               return _row;
          }
 
          public function set row(value:int):void
          {
               _row = value;
          }
 
          public function get column():int
          {
               return _column;
          }
 
          public function set column(value:int):void
          {
               _column = value;
          }
 
          public function get section():int
          {
               return _section;
          }
 
          public function set section(section:int):void
          {
               _section = section;
          }
 
          public function get isHeader():Boolean
          {
               return false;
          }
 
          /**
           * Overriden Functions
           */
 
          override protected function initializeStates():void
          {
               // Set up the skin states
               _upSkin = new Sprite();    
               setSkinState(SkinStates.UP,_upSkin);
 
               _downSkin = new Sprite();    
               setSkinState(SkinStates.DOWN,_downSkin);
               setSkinState(SkinStates.DOWN_SELECTED,_downSkin);
               setSkinState(SkinStates.SELECTED,_downSkin);
 
               showSkin(_upSkin);
          }
 
          override protected function setState(state:String):void
          {
               super.setState(state);
 
               var matrix:Matrix = new Matrix();
               matrix.createGradientBox(width,height,90/180*Math.PI);
 
               // Check to see what state is being set and then draw
               // the graphics on the background Sprite accordingly.
               if(state == SkinStates.UP)
               {                   
                    _background.graphics.clear();
                    _background.graphics.beginGradientFill(GradientType.LINEAR,
                              [0xffffff,0xf2f2f2,0xffffff],[1,1,1],[0,127,255],matrix);
                    _background.graphics.lineStyle(2,0x221206);
                    _background.graphics.drawRoundRect(20,10,width-35,height-20,7,7);
                    _background.graphics.endFill();
               }
               if(state == SkinStates.DOWN ||
                    state == SkinStates.DOWN_SELECTED ||
                    state == SkinStates.SELECTED)
               {                        
                    _background.graphics.clear();
                    _background.graphics.beginGradientFill(GradientType.LINEAR,
                              [0xaaaaaa,0xcfcfcf,0xaaaaaa],[1,1,1],[0,127,255],matrix);
                    _background.graphics.lineStyle(2,0x221206);
                    _background.graphics.drawRoundRect(20,10,width-35,height-20,7,7);
                    _background.graphics.endFill();
               }              
          }
 
          override protected function onAdded():void
          {
               super.onAdded();
               addChild(_background);
               addChild(_image);
               addChild(_name);
               addChild(_brewery);
               addChild(_beerType);
               addChild(_ratingText);
               addChild(_avgRating);
          }
 
          override protected function onRemoved():void
          {
               super.onRemoved();
               removeChild(_background);
               removeChild(_image);
               removeChild(_name);
               removeChild(_brewery);
               removeChild(_beerType);
               removeChild(_ratingText);
               removeChild(_avgRating);
          }
     }
}
  • Alessio

    Great job! Keep up the good work!

  • http://twitter.com/probertson Paul Robertson

    I’ve enjoyed the Playbook posts — hopefully I’ll be able to add a few of my own soon now that my quick project using those components is wrapping up.

    It almost seems like there’s an assumption in the skinning system that you’re using image skins that you’re swapping out rather than using the drawing api. For the itemrenderer skins I made recently I used images and it was pretty straightforward. (The complicating factor in my case was that I wanted additional states besides the ones that are built in, but that turned out to be pretty easy to add in.) Did you consider using scale-9 images for the states so they could still scale? Just curious (though scale 9 tends to not work well for gradients, so maybe that was an issue for you).

    I was okay with the default label, so I just extended CellRenderer. Like you I found a few key customization points:
    - In my CellRenderer, override the data setter (that’s how I got the custom states)
    - In my skin class, override initializeStates() to set things up
    - In my skin class, override draw() when I wanted to customize anything based on size changes (draw() is always called after setting the size)

    The last point isn’t one I used in my CellRenderer. I figured it out when I had a button skin that needed to be flexible based on size (in terms of positioning some children and masking a background) and it was easy to do by just overriding draw().

  • Texas Foreclosure Attorney

    Enjoyed your post

  • http://blog.digitalbackcountry.com Ryan Stewart

    Hey Paul, yeah, it does seem like the PlayBook skinning model is more geared towards images. I don’t really know why I liked the drawing API other than it seemed a bit cleaner. But I can’t quantify that at all. I don’t know what the performance implications are (I should check when I get an actual device).

    And thanks for the customization points on the CellRenderer. Let me know what you’re building when you get a chance. Would love to check it out!

  • http://japangold.blogspot.com/ Mariko Shinzu

    This is very helpful, thank you.

  • http://www.getgreen.co.za Get Green

    Ive been reading alot about the playbook and I want one badly! It would help so much in my line of work and these posts make it even more exciting!

  • Shanthanj

    Hi,

    I tried this example. But I am unable to get the gradient background and the border for the list. I tried debugging it. It turns out that the setState method is never getting executed in my code.
    Is there anything specific that I have to do for that?

    Can anyone please post a sample code on how to create and call the BeerCellRendererSkin object please??

    Thanks in advance for the help

  • http://blog.digitalbackcountry.com Ryan Stewart

    I know we helped you figure this out, but for others running into the same issue, on the list component you have to call setSkin() and set your specific skin.

    =Ryan

  • sheepherde

    Hi,

    Thanks for the great post. I’m kind of new in this. I have followed the code and somehow, the extent of each cell is not rendered properly and the border is drawn somewhat only around the name (as of in its height)..
    I’ve tried to set the height to a specific value to see it it handles it properly but the elements go on intersecting with each other. Have any idea?

  • Pingback: Working with PlayBook QNX UI components : Mihai Corlan

  • matt

    would it be possible to see the source file? I’m having a hell of a time getting something similar setup.

  • Rafaelje

    Please let me know how do you implement this class to get data, thanks!

  • Chernyshov Yuriy

    Hi 
    Ryan.
    Thanks for the blog and for the job You doing.
    I have a question regards a List – I do need a list (with horizontal scroll) with a different row height, for example  for a chat view. Is it possible to implement it via QNX List class? As I read, all rows of the List class has same height( …

  • Zach J

    Could not find documentation ANYWHERE that was in the least bit helpful in regards to skinning list elements… this post was a life saver!  As someone who is relatively new to ActionScript, thanks so much for a very clear and well written example!