Monday, October 19, 2009

Extensible List Item Renderers


For all of list-based components (DataGrid, HorizontalList, List, Menu, TileList, and Tree) you can implement custom item renderers to override the default rendering of data. For simple cases, you can just extend a component which implement IDropInListItemRenderer. For more complex controls, especially where the item renderers need to have varying heights, I have discovered that this approach becomes complicated. I have often run into layout issues with complex item renderers when used in List and Tree components where they have extra space between the renderers or the renderers overlap. This post will present a general approach to resolving these issues by using what I call an Extensible Item Renderer.

The approach involves first extending the default item renderer class for the particular ListBase component being used. In this post I will show implementations for the List and Tree components. The following code is for what I call the ExtensibleListItemRenderer:

package
{
  import mx.controls.listClasses.ListBase;
  import mx.controls.listClasses.ListItemRenderer;
  import mx.core.IDeferredInstance;
  import mx.core.UIComponent;
  import mx.events.FlexEvent;

  public class ExtensibleListItemRenderer extends ListItemRenderer
  {
    //We use a deferred instance to cause component instantiation
    //to be deferred until it is needed
    [InstanceType("mx.core.UIComponent")]
    public var contents:IDeferredInstance; 
    
    public function ExtensibleListItemRenderer()
    {
      super();
      
      addEventListener(FlexEvent.INITIALIZE, handleInitialize, false, 0, true );
    }
    
    private function handleInitialize( evt:FlexEvent ):void
    {
      //On initialize, add the custom contents to the renderer
      if ( contents ) addChild( UIComponent(contents.getInstance()) );
    }
    
    override protected function createChildren():void
    {
      super.createChildren();
      
      //hide the default label
      label.visible = false;
    }
    
    override protected function measure():void
    {
      super.measure();
      
      //The height of the renderer is set to the height of the contents
      if ( contents ) measuredHeight = measuredMinHeight = UIComponent(contents.getInstance()).getExplicitOrMeasuredHeight();
    }
    
    override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void
    {
      super.updateDisplayList( unscaledWidth, unscaledHeight );
      
      if( super.data ) 
      {        
        if ( contents )
        {
          //Position the contents using the position of the label
          UIComponent(contents.getInstance()).x = label.x;
          UIComponent(contents.getInstance()).y = label.y;
          
          //TForces the contents to calculate height by setting the width
          UIComponent(contents.getInstance()).width = width - UIComponent(contents.getInstance()).x;

          //This sets the components actual size to the calculated width & height
          UIComponent(contents.getInstance()).setActualSize( UIComponent(contents.getInstance()).getExplicitOrMeasuredWidth(), UIComponent(contents.getInstance()).getExplicitOrMeasuredHeight() );

          //This resolves an (apparent) bug in the ListBase component
          //If the calculated height of the renderer doesn't match what gets passed to this 
          //method, we need to tell the Tree to re-layout the renderers
          if ( unscaledHeight != measuredHeight && name != "hiddenItem" )  callLater( ListBase(owner).invalidateList );
        }
      }
    }
  }
}

In measure(), we are setting the height of the renderer based on the height of the contents. In updateDisplayList(), we position the component, force it to compute its size, and then size it using the computed size. If you do these things your item renderer will mostly work right, but there seems to be a bug in ListBase when the renderer changes size, it does not re-layout the renderers and they can overlap each other. This is what that looks like:


Unable to initialize flash content





The last bit of code in updateDisplayList() fixes this, by detecting that the unscaledHeight passed to the method is different than the calculated height and calling invalidateList on the ListBase component. We need to use callLater() to make the layout happen on the next render cycle since we are calling it from updateDisplayList(). The ExtensibleTreeItemRenderer is almost identical to the List version, except that it extends the default TreeItemRenderer to provide the open/close controls:

package
{
  import mx.controls.listClasses.ListBase;
  import mx.controls.treeClasses.TreeItemRenderer;
  import mx.core.IDeferredInstance;
  import mx.core.UIComponent;
  import mx.events.FlexEvent;

  public class ExtensibleTreeItemRenderer extends TreeItemRenderer
  {
    //We use a deferred instance to cause component instantiation
    //to be deferred until it is needed
    [InstanceType("mx.core.UIComponent")]
    public var contents:IDeferredInstance;
    
    public function ExtensibleTreeItemRenderer()
    {
      super();
      
      //On initialize, add the custom contents to the renderer
      addEventListener(FlexEvent.INITIALIZE, handleInitialize, false, 0, true );
    }
    
    private function handleInitialize( evt:FlexEvent ):void
    {
      if ( contents ) addChild( UIComponent(contents.getInstance()) );
    }
    
    override protected function createChildren():void
    {
      super.createChildren();
      
      //hide the default label
      label.visible = false;
    }
    
    override protected function measure():void
    {
      super.measure();
      
      //The height of the renderer is set to the height of the contents
      if ( contents ) measuredHeight = measuredMinHeight = UIComponent(contents.getInstance()).getExplicitOrMeasuredHeight();
    }
    
    override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void
    {
      super.updateDisplayList( unscaledWidth, unscaledHeight );
      
      if( super.data ) 
      {
        if ( contents )
        {
          //Position the contents using the position of the label
          UIComponent(contents.getInstance()).x = label.x;
          UIComponent(contents.getInstance()).y = label.y;
          
          //TForces the contents to calculate height by setting the width
          UIComponent(contents.getInstance()).width = width - UIComponent(contents.getInstance()).x;

          //This sets the components actual size to the calculated width & height
          UIComponent(contents.getInstance()).setActualSize( UIComponent(contents.getInstance()).getExplicitOrMeasuredWidth(), UIComponent(contents.getInstance()).getExplicitOrMeasuredHeight() );
          
          //This resolves an (apparent) bug in the ListBase component
          //If the calculated height of the renderer doesn't match what gets passed to this 
          //method, we need to tell the Tree to re-layout the renderers
          if ( unscaledHeight != measuredHeight && name != "hiddenItem" )  callLater( ListBase(owner).invalidateList );
        }
      }
    }
  }
}

In both of these renderers we use a component template approach to make it simple to extend them using mxml.

We start by creating a simple item renderer in mxml which extends an HBox container which I call ActorRenderer:

<?xml version="1.0" encoding="utf-8"?>
<mx:HBox xmlns:mx="http://www.adobe.com/2006/mxml">
  <mx:VBox>
    <mx:Image id="image" source="{data.image != null ? data.image : 'missing.jpg'}"/>
    <mx:Label id="name_label" fontWeight="bold" text="{data.name}"/>  
  </mx:VBox>
  <mx:Text htmlText="{data.bio}" width="{width - Math.max( image.width, name_label.width ) - 20}"/>
</mx:HBox>


The component consists of an image positioned on the left with a caption under it and a Text component on the right. For the example I use it display a photo of an actor with their name and biographical information. In order to get the Text component to wrap I explicitly set the width to an absolute value calculated from the width of the component.

I could drop this right into a List and it will mostly just work. My experience has been, though, that intermittently the List will not update the layout correctly due to the bug mentioned above. To get around it, we just wrap the ActorRenderer in the ExtensibleListItemRenderer:

<?xml version="1.0" encoding="utf-8"?>
<ExtensibleListItemRenderer xmlns="*" xmlns:mx="http://www.adobe.com/2006/mxml">
  <contents>
    <ActorRenderer data="{data}"/>
  </contents>
</ExtensibleListItemRenderer>

If we want to use our custom item renderer in a Tree, we wouldn't be able to just drop it directly into the Tree because we would then need to implement the item disclosure controls. We are pretty much forced to extend from the default TreeItemRenderer. Fortunately, by wrapping it inside of the ExtensibleTreeItemRenderer, it becomes super-simple to put the custom item renderer into a Tree:

<?xml version="1.0" encoding="utf-8"?>
<ExtensibleTreeItemRenderer xmlns="*" xmlns:mx="http://www.adobe.com/2006/mxml">
  <contents>
    <ActorRenderer data="{data}"/>
  </contents>
</ExtensibleTreeItemRenderer>


Here is the code for the example Application which displays the same set of data in a Tree and List:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute">
  <mx:Script>
    <![CDATA[    
    [Bindable] private var actors:Array = [ { name:'Leonard Nimoy', image:'nimoy.jpg', bio:'Leonard Simon Nimoy (pronounced /ˈniːmɔɪ/; born March 26, 1931) is an American actor, film director, poet, musician and photographer. He is famous for playing the character of Spock on the original Star Trek series, and he reprised the role in various movie and television sequels.<br><br><b>Source:</b> wikipedia.org',
                                              children : [ { name:'Adam Nimoy', image:null, bio:'Adam B. Nimoy (born August 9, 1956) is an American television director. Nimoy is the son of actor Leonard Nimoy and his first wife, actress Sandra Zober.' } ] },
                                            { name:'William Shatner', image:'shatner.jpg', bio:'William Alan Shatner (born March 22, 1931)[1] is a Canadian actor and novelist. He gained worldwide fame and became a cultural icon for his portrayal of Captain James T. Kirk, captain of the starship USS Enterprise, in the television series Star Trek from 1966 to 1969, Star Trek: The Animated Series and in seven of the subsequent Star Trek feature films. He has written a series of books chronicling his experiences playing Captain Kirk and being a part of Star Trek as well as several co-written novels set in the Star Trek universe. He has also authored a series of science fiction novels called TekWar that were adapted for television.<br><br><b>Source:</b> wikipedia.org' },
                                            { name:'DeForest Kelley', image:'kelley.jpg', bio:'Jackson DeForest Kelley (January 20, 1920 – June 11, 1999) was an American actor known for his iconic role Dr. Leonard "Bones" McCoy of the USS Enterprise in the television and film series Star Trek.<br><br><b>Source:</b> wikipedia.org' } ];
    ]]>
  </mx:Script>
  <mx:HBox width="100%" height="100%" horizontalGap="5" minWidth="0" minHeight="0">
    <mx:Panel title="Tree" width="100%" height="100%">
      <mx:Tree minHeight="0" minWidth="0" variableRowHeight="true" itemRenderer="ActorTreeItemRenderer" dataProvider="{actors}" width="100%" height="100%"
         folderClosedIcon="{null}" folderOpenIcon="{null}" defaultLeafIcon="{null}"/>      
    </mx:Panel>
    <mx:Panel title="List" width="100%" height="100%">
      <mx:List minHeight="0" minWidth="0" variableRowHeight="true" itemRenderer="ActorListItemRenderer" dataProvider="{actors}" width="100%" height="100%"/>
    </mx:Panel>
  </mx:HBox>
</mx:Application>


We populate the data provider with some example data, set variableRowHeight='true' for the Tree & List, and disable the icons on the Tree.



Here is the demo:


Unable to initialize flash content


Source is hosted on google code.

2 comments:

Unknown said...

Great post, very informative and I've used this as a basis for my needs.

I found a glitch though with the flex framework working within the tree control, where the disclosureIcons flicker under various circumstances. This is not to do with your code, but I thought I'd add my solution to complete this fine example:

All I do is override the commitProperties method of ExtensibleTreeItemRenderer as follows:

override protected function commitProperties():void
{
super.commitProperties();

disclosureIcon.x = TreeListData(listData).indent;
disclosureIcon.visible = TreeListData(listData).hasChildren;

var verticalAlign:String = getStyle("verticalAlign");
if (verticalAlign == "top")
{
if (disclosureIcon)
disclosureIcon.y = 0;
}
else if (verticalAlign == "bottom")
{
if (disclosureIcon)
disclosureIcon.y = unscaledHeight - disclosureIcon.height;
}
else
{
if (disclosureIcon)
disclosureIcon.y = (unscaledHeight - disclosureIcon.height) / 2;
}
}

Hope the code section posted ok.

(mplord)

Carlos said...

Very useful info. Thank you very much.