Sunday, July 13, 2008

Shoot Yourself In The Foot Using Sub-Menus



Both Chen and myself have very strong opinions about where LWUIT should and shouldn't go. These opinions range in reasoning from size considerations to UI aesthetics to code and portability.
We sometimes disagree but can usually convince each other and work by consensus. One area where we have a very strong agreement without ever going into the details is sub-menus. We don't like them.
Sub menus have lots of issues in mobile devices and in our opinion they should be avoided always in terms of UI aesthetics and usability. Generally we try to accommodate peoples desire to shoot themselves in the foot if they desire it, but since supporting submenus would add a great deal of complexity/overhead we don't want to burden everyone with this feature.

Why are submenus bad?

1. UI is Confusing - when scrolling over a submenu it "times out" and opens the sub menu, the length of time for the "time out" is variable between phones. This is really terrible in terms of usability since the user might start pressing a button and it might get delivered to the sub menu... What happens when a user presses a button and won't release it?

2. Nested menus are hard to navigate and make the depth of your functionality much harder to reach. I'm not a big advocate of the 3 click rule but I like the fact that on a quick view of the screen I can see most of the functionality of the application.

3. Touch screen interfaces make traditional menus somewhat problematic and nested menus almost impossible...

4. Inconsistent behavior across devices - some devices will implement submenus in one way and others would make timeouts different and change key press/release behavior.


What is the alternative?

Dialogs are probably the closest thing to a sub menu and they solve most of the issues above. A command can lead to a dialog that would prompt for additional details/give you a set of options.

After reading this far, if you still want submenus you can actually achieve them in LWUIT thanks to LWUIT's extensibility. Notice that sub menus require disabling transitions on menus otherwise the menu will fold before showing the submenu, we can probably disable transitions on the fly rather than on startup but this is a quick and dirty sample. Please notice that this code was tested on the latest LWUIT code base and might not compile/work on older versions, it should work OK with the drop we are planing for the 14th of July.
Feel free to extend this starting point and remember that you can do pretty much ANYTHING in LWUIT one way or another!
/**
* Allows building a hierarchy of components
*
* @author Shai Almog
*/

public class ParentCommand extends Command {
private Command[] children;
public ParentCommand(String name, Command[] children) {
super(name);
this.children = children;
}

public Command[] getChildren() {
return children;
}
}


/**
* Form with submenu implementation
*
* @author Shai Almog
*/

public class SubMenuForm extends Form {
private ParentCommand selection;
private static final int SUBMENU_POPUP_DELAY = 1000;
private Dialog menuDialog;

public SubMenuForm() {
init();
}

public SubMenuForm(String title) {
super(title);
init();
}

private void init() {
setMenuTransitions(null, null);
setMenuCellRenderer(new DefaultListCellRenderer() {
private boolean isSubMenu;
public Component getListCellRendererComponent(List list, Object value, int index, boolean isSelected) {
isSubMenu = value != null && value instanceof ParentCommand;
return super.getListCellRendererComponent(list, value, index, isSelected);
}

public void paintBackground(Graphics g) {
super.paintBackground(g);
if(isSubMenu) {
int oldColor = g.getColor();
if(hasFocus()) {
g.setColor(getStyle().getFgSelectionColor());
} else {
g.setColor(getStyle().getFgColor());
}
int leftPoint = getX() + getWidth() - 10;
int rightPoint = getX() + getWidth() - 2;
int centerPoint = getY() + (getHeight() / 2);
int topPoint = centerPoint - 4;
int bottomPoint = centerPoint + 4;
g.drawLine(leftPoint, topPoint, rightPoint, centerPoint);
g.drawLine(leftPoint, bottomPoint, rightPoint, centerPoint);
g.drawLine(leftPoint, topPoint, leftPoint, bottomPoint);
g.setColor(oldColor);
}
}
});
}

protected List createCommandList(Vector commands) {
List commandList = super.createCommandList(commands);
SelectionMonitor s = new SelectionMonitor(commandList);
commandList.addSelectionListener(s);
commandList.addActionListener(s);
return commandList;
}

protected Command showMenuDialog(Dialog menu) {
menuDialog = menu;
Command c = super.showMenuDialog(menu);
menuDialog = null;
return c;
}

class SelectionMonitor implements SelectionListener, Animation, ActionListener {
private List commandList;
private long selectTime;
public SelectionMonitor(List commandList) {
this.commandList = commandList;
}

public boolean animate() {
// we use the animate method as a timer, gauging the time that passed since a selection
// was made
if(selection == null) {
menuDialog.deregisterAnimated(this);
} else {
if(System.currentTimeMillis() - selectTime >= SUBMENU_POPUP_DELAY) {
menuDialog.deregisterAnimated(this);
showSubmenu();
}
}

return false;
}

public void selectionChanged(int oldSelected, int newSelected) {
Object o = commandList.getSelectedItem();
if(o != null && o instanceof ParentCommand) {
// cause the animation of the parent form to be invoked
menuDialog.registerAnimated(this);
selection = (ParentCommand)o;
selectTime = System.currentTimeMillis();
} else {
selection = null;
}
}

public void paint(Graphics g) {
}

public void actionPerformed(ActionEvent evt) {
menuDialog.deregisterAnimated(this);
if(selection != null) {
showSubmenu();
}
}

private void showSubmenu() {
final Dialog subMenu = new Dialog();
final List content = new List(selection.getChildren());
content.getStyle().setBgTransparency(0);
content.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent evt) {
subMenu.dispose();
Command c = (Command)content.getSelectedItem();
ActionEvent e = new ActionEvent(c);
c.actionPerformed(e);
actionCommand(c);
}
});
subMenu.setLayout(new BorderLayout());
subMenu.addComponent(BorderLayout.CENTER, content);
Command select = new Command("Select") {
public void actionPerformed(ActionEvent evt) {
Command c = (Command)content.getSelectedItem();
ActionEvent e = new ActionEvent(c);
c.actionPerformed(e);
actionCommand(c);
}
};
final Dialog oldMenuDialog = menuDialog;
Command cancel = new Command("Cancel") {
public void actionPerformed(ActionEvent evt) {
oldMenuDialog.show();
}
};
subMenu.setDialogStyle(menuDialog.getDialogStyle());
subMenu.addCommand(cancel);
subMenu.addCommand(select);
subMenu.show(getHeight() / 2 - 20, 20, getWidth() / 4, 20, true, true);
}
}
}
To use this sub menu implementation you can do the following:
Form submenuForm = new SubMenuForm("Sub Menu");
submenuForm.addCommand(new Command("Top 1"));
submenuForm.addCommand(new Command("Top 2"));
submenuForm.addCommand(new Command("Top 3"));
submenuForm.addCommand(new ParentCommand("Parent 1", new Command[] {
new Command("Sub 1"),
new Command("Sub 2"),
new Command("Sub 3"),
}));
submenuForm.addCommand(new ParentCommand("Parent 2", new Command[] {
new Command("Sub 4"),
new Command("Sub 5"),
new Command("Sub 6"),
}));
submenuForm.addCommand(new Command("Top 4"));
submenuForm.show();

1 comment:

  1. Hi shai,
    i've checked your code and from what i see a couple of problems happen.
    1. if the command thats being focused when the menu is open is a "parent" command then it doesnt work since selection is null (maybe selectionChanged should be called when the menu is opened with the old and new id by the form)
    2. when the dialog form with the submenus is open pressing Fire key (mid joystick) doesnt trigger the select command unlike when normal menu is open then fire == select

    ReplyDelete