Sunday, December 02, 2007

Making Flex Menus Easier

The Flex framework makes your life a lot easier when creating an application. However, not everything is necessarily as easy as it should be right out of the box; however, there are extensibility points within the framework to make this job easier. One such particular example is Menus. So let's look at a quick example:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute">
<mx:MenuBar showRoot="false" menuShow="menuShowHandler(event)" itemClick="menuClickHandler(event)">
<mx:XML>
<menuitem id="rootMenu">
<menuitem label="File">
<menuitem id="menuNew" label="New"/>
<menuitem label="Open"/>
</menuitem>
<menuitem label="Edit">
<menuitem id="cut" label="Cut"/>
<menuitem id="copy" label="Copy"/>
<menuitem id="paste" name="paste" label="Paste" enabled="false"/>
<menuitem type="separator" />
<menuitem label="Other menu item"/>
</menuitem>
<menuitem id="windowMenu" label="Window">
<menuitem id="d1" label="Document 1" type="radio" group="docs" toggled="false"/>
<menuitem label="Document 2" type="radio" group="docs" toggled="true"/>
<menuitem id="d3" name="d3" label="Document 3" type="radio" group="docs" toggled="false"/>
<menuitem label="Document 4" type="radio" group="docs" toggled="false"/>
</menuitem>
</menuitem>
</mx:XML>
</mx:MenuBar>

<mx:Script>
<![CDATA[
import mx.events.MenuEvent;

private function menuClickHandler(event:MenuEvent):void {
switch (event.label) {
case "Cut":
cutHandler(event);
break;
case "Copy":
copyHandler(event);
break;
// many more cases if needed....
}
}

private function menuShowHandler(event:MenuEvent):void {
switch (event.label) {
case "Edit":
editHandler(event);
break;
// many more cases if needed....
}
}

private function cutHandler(event:MenuEvent):void {
trace("cut event");
}

private function copyHandler(event:MenuEvent):void {
trace("copy event");
}

private function editHandler(event:MenuEvent):void {
trace("edit event");
}
]]>
</mx:Script>
</mx:Application>

So you look at that, and it's pretty damn easy to create a menu. You create a menu based off of a simple dataProvider that's written in an easy-to-understand XML format. So what's wrong with it? Well, I think there are a few problems:

  • The XML is untyped, so it's easy to make a mistake or forget what the properties are (type, not typed and toggle, not toggled, etc...). It's also easy to forget the values the properties take (for example: type takes check, radio, separator and not checked, checkbox, or something similar)
  • Databinding is not supported, so to disable the cut, copy, and paste menu items, we'd have to access the dataProvider for all three menu items and change it manually. We'd have to do this in a special method and remember to do it everytime, whereas if data binding was supported, it'd be done for us automatically when one property changes (for example, when a textarea has focus or something)
  • Events are global for the whole menu. It's much better to attach events at the most specific point possible because we can avoid large, monolithic functions that essentially just dispatch the event somewhere else based on a large switch-case statement. Let the "framework" handle that for us.

Ok, so those are atleast some of the problems that I have with the current state of menus and specifying the data provider. However, we can fix these problems pretty easily. Since our data provider can be an object (not just XML), we can take advantage of this because MXML really just compiles down into an ActionScript object. Because MXML is essentially a typed object, we solve problem #1 (it can even tell us that type accepts check, radio, or seperator as inputs). Since MXML supports databinding, we solve problem #2, and with some functions of our own, we can define events on our new MXML object and get it routed to these more specific events and solve problem #3.

Here's what the example above will look like when done:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" xmlns:local="*">
<mx:MenuBar showRoot="false" menuShow="menuItemEventHandler(event)" menuHide="menuItemEventHandler(event)"
itemClick="menuItemEventHandler(event)" itemRollOut="menuItemEventHandler(event)"
itemRollOver="menuItemEventHandler(event)" change="menuItemEventHandler(event)">
<local:MenuItem id="rootMenu">
<local:MenuItem label="File">
<local:MenuItem id="menuNew" label="New"/>
<local:MenuItem label="Open"/>
</local:MenuItem>
<local:MenuItem label="Edit" menuShow="editHandler(event)">
<local:MenuItem id="cut" label="Cut" itemClick="cutHandler(event)"/>
<local:MenuItem id="copy" label="Copy" itemClick="copyHandler(event)"/>
<local:MenuItem id="paste" name="paste" label="Paste" enabled="false"/>
<local:MenuItem type="separator" />
<local:MenuItem label="Other menu item"/>
</local:MenuItem>
<local:MenuItem id="windowMenu" label="Window">
<local:MenuItem id="d1" label="Document 1" type="radio" group="docs" toggled="false"/>
<local:MenuItem label="Document 2" type="radio" group="docs" toggled="true"/>
<local:MenuItem id="d3" name="d3" label="Document 3" type="radio" group="docs" toggled="false"/>
<local:MenuItem label="Document 4" type="radio" group="docs" toggled="false"/>
</local:MenuItem>
</local:MenuItem>
</mx:MenuBar>

<mx:Script>
<![CDATA[
import mx.events.MenuEvent;

private function menuItemEventHandler(event:MenuEvent):void
{
if (event.item is MenuItem)
{
EventDispatcher(event.item).dispatchEvent(event);
}
else if (event.menu && event.menu.dataProvider &&
event.menu.dataProvider[0] is MenuItem &&
event.menu.dataProvider[0].parent is MenuItem)
{
EventDispatcher(event.menu.dataProvider[0].parent).dispatchEvent(event);
}
}
]]>
</mx:Script>

<mx:Script>
<![CDATA[
private function cutHandler(event:MenuEvent):void
{
trace("cut event");
}

private function copyHandler(event:MenuEvent):void
{
trace("copy event");
}

private function editHandler(event:MenuEvent):void
{
trace("edit event");
}
]]>
</mx:Script>
</mx:Application>

You can see the code above looks basically the same, but it does solve all of our problems. We can even reference MenuItems by id's now, treating them as first-class objects. The only tricky part is the menuItemEventHandler defined that you'll also need. This grabs the appropriate MenuItem object and then dispatches the event to the event handler defined for that MenuItem. This is different then the switch-case statement above because it doesn't actually know anything about our menu data -- it just dispatched it through to the event defined on the menu item, so it's the same for all menus.

So how was this so easily accomplished? Well it's with a new MenuItem class. It's basically just an object with some properites, like type, enabled, name, etc... and one important property, called children that's an array of MenuItems. In its simplest form, MenuItem looks like:

package
{
[Event(name="change", type="mx.events.MenuEvent")]
[Event(name="itemClick", type="mx.events.MenuEvent")]
[Event(name="menuHide", type="mx.events.MenuEvent")]
[Event(name="menuShow", type="mx.events.MenuEvent")]
[Event(name="itemRollOut", type="mx.events.MenuEvent")]
[Event(name="itemRollOver", type="mx.events.MenuEvent")]

[DefaultProperty("children")]
public class MenuItem extends EventDispatcher
{
public function MenuItem() {
}

[Bindable]
public var enabled:Boolean = true;

[Bindable]
public var toggled:Boolean;

public var name:String = null;

public var group:String = null;

public var parent:MenuItem = null;

public var label:String = null;

[Inspectable(category="General", enumeration="check,radio,separator")]
public var type:String = null;

public var icon:Object = null;

private var _children:Array = null;

[Inspectable(category="General", arrayType="MenuItem")]
[ArrayElementType("MenuItem")]
public function set children(c:Array):void
{
children = c;
if (c)
for (var i:int = 0; i < c.length; i++)
c[i].parent = this;
}

public function get children():Array
{
return _children;
}

// functions for manipulating children:
}
}

Two cool things to note here are with the meta-data. The first is with [DefaultProperty("children")] defined above the class. This tells the compiler that when children are added in MXML, what property it actually correlates to. Another example of this is in the Flex framework for List and Menu where dataProvider is the default property. Another metadata trick is with [Inspectable(category="General", enumeration="check,radio,separator")] where we tell Flex Builder what the type property accepts so that code-hinting works quite nicely.

So the code above actually work perfectly fine, but I added some extra methods for dealing with children (adding, find, remove, etc...), which I will disclose aren't tested very well. I've uploaded the full MenuItem.as code so you can download it. Mike Schiff, another Flex SDK engineer, was the first one to point out this technique, and his code was the basis for everything here (in fact, it was probably most of it).

This is a really simple technique but it helps with a few pits of creating menus in Flex. Not only that, but it can be applied to all components based on data providers. If you want to define a similar MXML object but want to have non-standard properties, like myType instead of type, you can tell the Flex framework this by using either dataDescriptor or something like labelField/labelFunction.