Tuesday, March 25, 2008

Lines and Tigers: Customizing ComboBox

In the Top Drawer application that I posted recently, wanted a custom combobox that showed the line widths as graphical lines, not as text descriptions. Text is a pale substitute for graphics, don't you think? Think how much more boring Hamlet would have been if it had just been a bunch of words. Or Anna Karenina. Or think how exciting and immersive everey episode of Sponge Bob Square Pants is, all because it's drawn in such meticulous detail.

I got part-way to my goal in Top Drawer, with the drop-down list showing lines instead of labels. But the main combobox button still showed a text label, instead, "width 1":

Partial Achievement

Now that I finally finished the exposee on Top Drawer, it's time to revisit the combobox and see if we can spruce it up a bit.

Take I: Default ComboBox with Boring Text Labels

First, let's see the default we'd get if we just coded up a normal ComboBox to handle line widths. In this case, I want to assign a width between 1 and 5 to my lines; I create a ComboBox with a list of objects that have width attributes.

    <mx:ComboBox id="lineWidth" x="10" y="189" width="87"
            editable="false">
        <mx:ArrayCollection>
            <mx:Object width="1" label="width 1"/>
            <mx:Object width="2" label="width 2"/>
            <mx:Object width="3" label="width 3"/>
            <mx:Object width="4" label="width 4"/>
            <mx:Object width="5" label="width 5"/>
        </mx:ArrayCollection>
    </mx:ComboBox>

The width attribute is used as a source in a databinding expression for the ArtCanvas object, where it sets the current stroke width based on the width value of the currently selected item in the ComboBox.

    <comps:ArtCanvas id="canvas" 
            drawingColor="{drawingColorChooser.selectedColor}"
        strokeWidth="{lineWidth.selectedItem.width}"
        left="105" top="10" right="10" bottom="10"/>

This ComboBox does the job, allowing the user to set the width of the lines interactively through the GUI with the small amount of code above to hook it up. But the user experience is not quite what a graphics geek might want:

Default Line Width ComboBox

Take II: Graphics in the Drop-Down

Now we come to the version that I developed in the Top Drawer application; I attacked the drop-down list to make the elements in that list graphical instead of the boring text-based "width x" versions above.

To do this entails the Flex notion of an "item renderer." Item renderers are used by the list-based components, such as ComboBox, allowing applications to specify objects which will perform custom rendering of each item in the list. Specifying the item renderer in MXML is done by simply naming the class that will be used to render the items. In our application, we use the class LineWidthRenderer, which is specified in the same mx:ComboBox tag as above:

    <mx:ComboBox id="lineWidth" x="10" y="189" width="87"
            itemRenderer="components.LineWidthRenderer" editable="false">
        <mx:ArrayCollection>
            <mx:Object width="1" label="width 1"/>
            <mx:Object width="2" label="width 2"/>
            <mx:Object width="3" label="width 3"/>
            <mx:Object width="4" label="width 4"/>
            <mx:Object width="5" label="width 5"/>
        </mx:ArrayCollection>
    </mx:ComboBox>

The code in our LineWidthRenderer class (written in ActionScript3) that handles rendering each item is in the standard setter for the data field; this method will be called by Flex whenever it needs to display a particular item. In our case, we know that each data item will contain a width attribute, so we check the value of that attribute and perform our rendering appropriately:

        override public function set data(value:Object):void
        {
            _data = value;
            renderLine(graphics, 5, 50, _data.width);
        }

        public static function renderLine(graphics:Graphics, 
                x0:int, x1:int, lineWidth:int)
        {
            graphics.clear();
            graphics.lineStyle(lineWidth, 0x000000);
            graphics.moveTo(x0, 10);
            graphics.lineTo(x1, 10);
        }

Here, our data setter is calling the renderLine() method in the same class, which takes a Graphics object, endpoints for the line, and a line width value and draws an appropriate line into the display list of the graphics object. The result is much better than before:

Better Line Width Rendering

Finally, we're rendering our line width choices as actual lines. The user can see visual representations of what they will get in the shapes they draw and can make a better selection between the UI choices. Besides, it just looks cooler.

But it's still not quite good enough; what's with that "width 1" label in the ComboBox's button?

Take III: ComboBox Nerdvana

Not only does the button of the ComboBox display text when the drop-down list is nicely graphical below it; the closed ComboBox displays only that text label. Gone is all of the lovely wide-line artwork that we slaved over for the drop-down list.

The final step, then, is to customize our ComboBox further to provide a graphical representation of the currently selected item in the top-level button.

This turns out to be a simple problem to solve. So simple, in fact, that I felt silly that I didn't solve it before I shipped the initial version of the application. But what would life be like if all our TODOs were impossible? Here's the solution:; we need a subclass of ComboBox with customized rendering for the button.

We create a subclass of ComboBox in ActionScript3, LineWidthComboBox, as follows:

package components
{
import mx.controls.ComboBox;

public class LineWidthComboBox extends ComboBox
{
    override protected function updateDisplayList(
            unscaledWidth:Number, unscaledHeight:Number):void
    {
        LineWidthRenderer.renderLine(graphics, 10, 55, selectedItem.width);
    }
}
}

Not very complex, is it? The subclass exists to override the single method updateDisplayList(), which is where a component creates the rendering for its contents. In this case, we call the same static renderLine() method as we used before in our LineWidthRenderer class to draw a graphical representation of the currently-selected line width into our component. But apparently this isn't enough, since this is what results:

Where'd the Buttoin Go?

The problem is that we're not bothering to render the actual button object of our ComboBox, so the typical button background that we expect is not there. Since we're lazy and don't want to do all of that work in our code, we can ask the superclass to take care of the rendering for this component for us by simply calling the superclass' updateDisplayList() method first, and then performing our custom rendering on top of it:

    override protected function updateDisplayList(
                 unscaledWidth:Number, unscaledHeight:Number):void
    {
        super.updateDisplayList(unscaledWidth, unscaledHeight);
        LineWidthRenderer.renderLine(graphics, 10, 55, selectedItem.width);
    }

This is better, but still not quite what we want:

Label and Line

Now we have our button and a graphical line, but we also have the default text label which our superclass is kindly drawing for us. We want the superclass to draw the button decoration, but we don't want it to draw the text label. How can we fix this?

There are probably several ways to do this, but one easy way is to simply supply a label that won't get drawn.

By default, ComboBox looks for a "label" attribute in each item in the list and renders the string associated with that attribute. In our original mxml code for the ComboBox, we supplied items with both a width property (which supplies the information we use to actually determine the stroke width) and a label property, as follows:

        <mx:ArrayCollection>
            <mx:Object width="1" label="width 1"/>
            <mx:Object width="2" label="width 2"/>
            <mx:Object width="3" label="width 3"/>
            <mx:Object width="4" label="width 4"/>
            <mx:Object width="5" label="width 5"/>
        </mx:ArrayCollection>

We could simply remove the label:

        <mx:ArrayCollection>
            <mx:Object width="1"/>
            <mx:Object width="2"/>
            <mx:Object width="3"/>
            <mx:Object width="4"/>
            <mx:Object width="5"/>
        </mx:ArrayCollection>

but then we wouldget the following instead:

Bad Label and Line

The problem this time is that ComboBox is determined to find a text label, or if it doesn't find one it will create one of its own, which is obviously not what we want.

What our button really needs is an empty label; so let's provide it:

        <mx:ArrayCollection>
            <mx:Object width="1" label=""/>
            <mx:Object width="2" label=""/>
            <mx:Object width="3" label=""/>
            <mx:Object width="4" label=""/>
            <mx:Object width="5" label=""/>
        </mx:ArrayCollection>

With this change, we've pointed the ComboBox to label properties that it can use, but when our superclass goes to display the text, nothing will happen - which is just what we want:

Just the Line

Finally, with our custom ComboBox subclass, our reliance on the superclass rendering facilities for the button basics, and our fabulous new empty labels, in addition to our previous use of a custom item renderer for the drop-down list, we have what we set out to achieve: a completely graphical line-width control:

Completely Graphical Line Width Control

Here are the update sources Top Drawer. Stay tuned for more improvements in upcoming posts.

11 comments:

Anonymous said...

hi chet, this is kind of off topic. why did you move to adobe? i mean, what did convince you?

thx, jiri

Anonymous said...

hello chet
i want ask u about why not using 0
on first arrow
as
width="0"

I have seen it before on some sites and i don’t know for what they put it !

Chet Haase said...

kladofora: Good question; the use of 1 seems arbitrary here. I actually was using 0 initially, but it conflicted with my selection/moving mechanism. That is, it's not possible to detect a click on a 0-pixel-wide line, at least not when you use the selection mode I'm using (exactl pixels touched by a shape, not area surrounded by it).

Since it was meant to be a simple application, I just side-stepped the issue by making 1 the thinnest width available...

Chet.

Unknown said...

Thanks for the great step-by-step! I'm facing a similar issue and this is by far the most descriptive/helpful post I've found. In my case I don't need to draw a line, just render an image (no text) for each option. Could I follow a similar approach to get that to work? I'm not sure what call I would make to Image where you call LineWidthRenderer.renderLine. Any tips appreciated. Thanks!

Chet Haase said...

tomeast: Actually, I think your situation is easier than mine. Buttons already have the capability to handle icons in addition to text, so you should be able to supply your images as icons. If you don't want the text, you could use my simple hack of having empty labels.

I haven't actually done this, it just seems theoretically possible (and easier than my situation of drawing vector graphics into the component). Alex Harui discussed icons in ComboBox (in addition to text labels) in this blog post: http://blogs.adobe.com/aharui/2007/04/icons_in_combobox.html

Chet.

Unknown said...

Thanks for the quick response! You are right, combining the technique mentioned in the supplied post along with your empty label hack got me the result I was looking for. Thanks again!

Regis said...

Hey Chet - thanks for the tip! I have been trying to force the comboBox button to display html text, but without any success. Would you know how to go about this? I have to say that extending the component is a bit hard for me...
Thanks for your time,
Regis

loarndo said...

I have seen it before on some sites and i don’t know for what they put it !

Free man said...

thank you

nice..

ipk said...

Fabulous sample...

Monica said...

Hi,

This post ir really very good.
But I am facing a problem using the above code.On drop down I could see the lines but on selecteditem I am line is not visible though line width is properly selected.When I added label (I kept it same as u mentioned "")it is showing properly.Could you help me to get the selected line on the selected item of combobox.