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.

25 comments:

Anonymous said...

Great idea!
Tried a simple cut and paste of your .as file, but get an error of...

[ArrayElementType](MenuItem)]: type MenuItem is unavailable.

... and menus don't work in Flex 3.

did you come across this in your development?

thanks in advance,

dp

Ryan said...

dp,

Don't just copy and paste the code. I just leave that there as example stuff. Download the MenuItem.as file that I uploaded.

Hope that helps,
Ryan

bladnman said...

Nope, downloaded the .as and it works, so that is a start (flex 3 air project)... but I still get a warning on all parent items that have children... it's the

[ArrayElementType](MenuItem)]: type MenuItem is unavailable.



It reports on the first line of
dto:MenuItem label="File"
dto:MenuItem id="menuNew" label="New"/
dto:MenuItem label="Open"/
/dto:MenuItem

bladnman said...

Nope, downloaded the .as and it works, so that is a start (flex 3 air project)... but I still get a warning on all parent items that have children... it's the

[ArrayElementType](MenuItem)]: type MenuItem is unavailable.

bladnman said...

Remove these lines from the .AS file to get this to work in Flex 3.



[Inspectable(category="General", arrayType="MenuItem")] [ArrayElementType("MenuItem")]

Ryan said...

Hmm, make sure dto's the right namespace. You can also remove the:

[ArrayElementType("MenuItem")]

In the MenuItem.as file if that doesn't seem to fix it. If you still can't get it, send me an email with your project file.

Anonymous said...

Ryan,

have you found an elegant way to handle the click event for a top level menu? I can't seem to make the event fire for these events.

For example, if you have

local:MenuItem id="close" label="Close" itemClick="closeHandler(event)"

and this is not a submenu... the event isn't being dispatched.

Ryan said...

Hey Tony,

Thanks for pointing that out. It's a problem with MenuBar itself. It wasn't really made to handle top-level menu-items. I filed a bug for this feature: SDK-14805. Please vote on it (and get friends) if you think it's an important issue you'd like to see fixed coming up. Also, now that we're open-source (as of Monday), you can even attach a patch to the bugfile that an engineer will review and check-in.

-Ryan

Anonymous said...

Hey Ryan...

Another question...

Have you ever tried to control the 'enabled' property thru data binding?

It seems I can do this...

MenuItem name="mnuClose" label="Close" enabled="{closeEnabled}"

and it will bind to the initial value of closeEnabled... but when I change closeEnabled, it does not get updated.

Again, this is only for top level menus. Submenus change like they should.

I think I'm going to have to restructure my menus to avoid these problems.

Tony

janviehweger said...

hi ryan,

thanx for this good example - very well explained. i playe arround with it an stumbled over one thing:

i'd like to add new MenuItems at runtime. but this causes a TypeError: #1009. when i explicit set the 'children' attribute in the mxml tag of the parent MenuItem to [] the error is gone. but the new MenuItem doesn't appear and the children prop. keeps zero. hmmmm

in my script tag a have a function called 'addMenuItem' which is caleld by a button click.

....
public function addMenuItem():void
{
var item:MenuItem = new MenuItem();
item.label = 'NewItem';
item.type = 'radio';
myMenu.addChild(item);
}

any idea whats going wrong?

best regards
jan v.
halle s.
germany

Ryan said...

I don't know what error you got. I checked out addChildAt, and I see there's a small bug. splice() modifies the original array and returns an array of the items deleted, so I was using it incorrectly.

I've uploaded a fixed version.

Unknown said...

the top-level menu items cause a problem when using qtp to automate.

qtp only records show event for top-level menu items without submenus, which actually may associated with url that forwards to other pages when clicked.
But when replay recorded scripts on these top-level menu items, no expected result comes out. Have any idea solving this problem?

Ryan said...

Without having looked into it too much, the best thing I'd say is to vote on the bug, SDK-14805. We are open source, so anyone who wants to try their hand at fixing the bug is encouraged to.

Anonymous said...

thanks for your explaining example. For an Non-Professionle not easy to understand.

Anonymous said...

Let's assume the path within your project to the MenuItem.as file is src/view/MenuItem.as.
Your package, then, would be 'view', so the fully qualified name of the MenuItem class would be 'MenuItem.as'.
Try using the fully qualified name with your ArrayElement metadata tag. Using [ArrayElement("view.MenuItem")] should work.

Anonymous said...

Thanks for this great solution. Would you be able to share an example for adding and removing children from the menu?

Thanks!

Anonymous said...

I've been working with the addChild() and removeChild() methods in the menuItem.as and I've noticed that I cannot add or remove top level menuitems. I can only add or remove nested menuitems that are not visible by default.

I suspect this is a limitation of the menubar component, but I am not sure.

Anyone have similar findings?

Jason

Ryan said...

The downloadable MenuItem.as file has the addChild/removeChild methods implemented (although I didn't test them fully).

As for the menubar thing, I filed a bug about this here: SDK-14805. Please vote on the bug if you want it fixed.

Anonymous said...

Hum, my MenuBar is not showing for some reason. I'm adding this to a subclased TitleWindow (ResizableTitleWindow) instead of directly to the Application. I'm new to Flex, this shouldn't matter right? Any ideas why the MenuBar would not show?

dhoffer said...

How can I set the MenuItem's icon? I see it has a public icon var but it doesn't seem to be used (new to Flex so perhaps some majic is involved here). Also I note the type is Object but a normal icon type would be Class I believe. I have all my image assets defined in Embedded static class vars, how can I use these with your MenuItem class?

Anonymous said...

...update on previous post. Got the MenuBar to show in ResizableTitleWindow, no idea what I did. Flex weirdness if you ask me.

dhoffer said...

I figured it out, yup flex is weird.

The problem is that the icon var defined in my mx:Script has to be PUBLIC in order for the MenuItem's set icon property to work although all this is done in the same class/file. This doesn't make any since to me, it seems private scope should be fine within a class. Furthermore, the compiler/IDE doesn't help and warn that there is a problem you just find out at runtime!?!

Anonymous said...

This worked great for MenuBar can you show how to do the same for ButtonBar? It seems it has the same limitations and could use the same approach. I'm new to flex and need a little help with this...

Anonymous said...

Thanks Ryan for the wonderful piece of code.

kogare said...

hi, im not sure if my comment still relevant because now in flex 4 you can bind data to menuitem by using XMLList when you declare it in fx:declarations