Расширение функциональности элементов пользовательского интерфейса в Java
На примере реализации нестандартного для Swing поведения меню.
22 сентября 2006 года
Довольно часто при создании приложений с GUI (stand alone приложений или апплетов) приходится сталкиваться с необходимостью несколько изменить внешний вид и поведение стандартных компонентов пользовательского интерфейса. Иногда этого хочет заказчик. Иногда этого требует дизайнер интерфейсов. Так или иначе время от времени такая задача возникает. И не всегда представляется возможным создать комбинированный из нескольких других элемент пользовательского интерфейса. Например добавить кнопку закрытия в закладку компонента JTabbedPane. Или реализовать меню, которое может отображать пиктограмму над названием пункта меню и кроме этого позволит использовать JMenuBar непосредственно как контейнер для пунктов меню. Вот такое меню мы и реализуем.
Практически каждый кто писал на java приложения с GUI, использовал в своей работе классы имплементирующие меню. И наверняка каждый знает, что при создании меню есть определенные правила, все это хорошо описано в сановском туториал, книгах и статьях. И каждый знает об ограничениях, которые накладывает этот подход – чтобы использовать JMenuItem, необходимо создать экземпляр JMenu – контейнер для нашего итема, и уже только этот контейнер мы можем поместить в JMenuBar. Но ведь во многих windows-приложениях пункт меню может быть виден и использоваться прямо из панели меню. К сожалению разработчики Swing не заложили такой возможности в библиотеку. На самом деле это не сложно изменить. Для того чтобы проделать такой фокус нам нужно будет расширить класс JMenu – должен заметить, что мне не удалось реализовать прослушивание событий как это принято, пришлось пойти на ухищрения и изменить эту схему. Если кто то знает как это можно сделать, буду благодарен за комментарии и дополнения.
Итак, задача: в создаваемом приложении реализовать пункт меню, который будет находиться непосредственно в панели меню, иметь возможность использовать картинки и текст, и его поведение будет соответствовать стандартному пункту меню – если какой то пункт выбран – можно перемещаться с помощью клавиш курсора влево-вправо и реагировать на клавиши пробел и Enter. Выглядеть это будет примерно так:
При этом наше меню должно отображать пиктограмму и название итема – пиктограмма сверху а текст снизу, при этом корректно реагировать на ситуации когда пиктограмма или текст отсутствуют, и иметь возможность помещать как отдельные элементы, так и элементы-контейнеры.
- Таким образом задача разбивается на следующие подзадачи:
- расширить функциональность классов которые создают меню чтобы их поведение удовлетворяло поставленным требованиям
- корректно отрисовать наше меню.
Начнем реализацию со второй подзадачи.
Так как правильно и однотипно в панели меню отображаться должны и элементы-контейнеры(JMenu) и элементы-итемы (JMenuItem) напрашивается решение унаследовать наш класс от JMenu. Так мы и сделаем. Все что нужно будет изменить в нашем классе – это определить размеры нашего пункта меню и отрисовать его.
В классе обьявлено несколько констант для отступов от границ пунктов меню и между пиктограммой и текстом, а также значения размера для пункта меню по умолчанию, если ни текст ни пиктограмма не были заданы. Также обьявлены экземплярные переменные icon и title для реализации заявленной функциональности – отображения пиктограммы и надписи.
public class PictMenu extends JMenu { static final long serialVersionUID = -1; protected final int DEFAULT_WIDTH = 50; protected final int DEFAULT_HEIGHT = 24; protected final int TOP_DROP = 7; protected final int PICT_DROP = 10; protected final int BOTTOM_DROP = 7; protected final int STR_DROP = 10; protected final int BETWEEN_DROP = 0; protected int px = 0; protected int py = 0; protected int sx = 0; protected int sy = 0; protected ImageIcon icon = null; protected String title = "";
В классе имплементированы два метода и конструктор с параметрами. В конструкторе инициализируем экземплярные переменные и определяем соответствующие размеры пункта меню.
public PictMenu(String title, ImageIcon pict) { super(""); this.title = title; this.icon = pict; Dimension dim = computeSize(); setPreferredSize(dim); setMinimumSize(dim); } protected Dimension computeSize() { int w = DEFAULT_WIDTH; int h = DEFAULT_HEIGHT; int separator = BETWEEN_DROP; int strWidth = 0; int strHeight = 0; int pictWidth = 0; int pictHeight = 0; if ((title == null || title.length() == 0 ) && icon == null) { return new Dimension(w, h); } else { if (title == null || title.length() == 0) { separator = 0; } else { Font font = getFont(); FontMetrics fm = getFontMetrics(font); strWidth = fm.stringWidth(title); strHeight = fm.getHeight(); } if (icon == null) { separator = 0; } else { pictWidth = icon.getIconWidth(); pictHeight = icon.getIconHeight(); py = TOP_DROP; } w = (pictWidth >= strWidth) ? (pictWidth + PICT_DROP * 2) : (strWidth + STR_DROP * 2); px = (w - pictWidth) / 2; sx = (w - strWidth) / 2; } h = TOP_DROP + pictHeight + separator + strHeight + BOTTOM_DROP; sy = h - BOTTOM_DROP; return new Dimension(w, h); }
Второй метод ответственнен за отображение. Вообще, чтобы нарисовать на Swing-компоненте то что нам хочется, достаточно переопределить метод paintComponent(Graphics). Метод обьявлен в классе JСomponent и служит для вызова делегированного paint() метода соответствующего ComponentUI обьекта. Метод isSelected() родительского класса JMenu возвращает true если наш пункт меню был выбран. В этом случае мы добавляем прорисовку рамки чтобы визуально выделить выбранный пункт и сдвигаем картинку и надпись на 1 пиксел вниз-вправо.
public void paintComponent(Graphics g) { super.paintComponent(g); if (isSelected()) { g.setColor(new Color(244, 244, 244)); g.fillRect(0, 0, getWidth(), getHeight()); g.setColor(Color.WHITE); g.drawLine(0, 0, getWidth() - 1, 0); g.drawLine(0, 1, getWidth() - 2, 1); g.drawLine(0, 2, getWidth() - 3, 2); g.drawLine(0, 0, 0, getHeight() - 1); g.drawLine(1, 0, 1, getHeight() - 2); g.drawLine(2, 0, 2, getHeight() - 3); g.setColor(Color.BLACK); g.drawLine(1, getHeight() - 1, getWidth() - 1, getHeight() - 1); g.drawLine(getWidth() - 1, 1, getWidth() - 1, getHeight() - 1); g.setColor(new Color(204, 204, 204)); g.drawLine(2, getHeight() - 2, getWidth() - 2, getHeight() - 2); g.drawLine(getWidth() - 2, 2, getWidth() - 2, getHeight() - 2); g.setColor(new Color(224, 224, 224)); g.drawLine(3, getHeight() - 3, getWidth() - 3, getHeight() - 3); g.drawLine(getWidth() - 3, 3, getWidth() - 3, getHeight() - 3); g.setColor(Color.BLACK); if (icon != null) g.drawImage(icon.getImage(), px + 1, py + 1, this); if (title != null && title.length() > 0) g.drawString(title, sx + 1, sy + 1); } else { if (icon != null) g.drawImage(icon.getImage(), px, py, this); if (title != null && title.length() > 0) g.drawString(title, sx, sy); } }
Вторая часть нашей задачи – сделать возможным добавление итема в контейнер JMenuBar. Казалось бы очевидное решение – изменить поведение контейнера чтобы он корректно обрабатывал добавление итемов. Однако на самом деле это не будет самым простым решением – придется переписать довольно много кода и придется создать класс, который будет наследовать JMenuItem и прорисовывать итем аналогично тому как мы сделали в первой части задачи. Можно пойти другим путем. У нас уже есть класс который умеет себя правильно и однотипно прорисовывать в контейнере. Контейнер корректно размещает инстансы этого класса и передает сообщения о действиях пользователя. Что нам нужно – это как то спрятать выпадающий список включенных элементов и дать возможность программисту реализовывать реакцию на действия с этим пунктом меню.
Необходимость прятать выпадающий список появляется из-за того, что даже если мы не поместим ни одного итема в контейнер, то класс JMenu прорисовывает рамку этого списка, такой квадратик 2х2 пиксела. Выглядит не очень красиво. Этот выпадающий список реализован как popup-menu, координаты для отображения которого рассчитываются контейнером. Доступ обьявлен как private, напрямую добраться к нему нельзя. Но есть public - метод getPopupMenu(), и мы установим для внутреннего popup-menu размер 0х0 пикселов. Все, больше мы его не увидим.
Итак, создаем наследника от ранее нами созданного класса PictMenu.
В конструкторе делаем вызов getPopupMenu().setPreferredSize(new Dimension(0, 0));
Еще мы должны диспетчеризовать события клавиатуры, поэтому наш класс будет реализовывать интерфейс KeyEventDispatcher и мы зарегистрируем его в DefaultKeyboardFocusManager.
public class SimplePictMenu extends PictMenu implements KeyEventDispatcher { static final long serialVersionUID = -1; public SimplePictMenu(String title, ImageIcon pict) { super(title, pict); getPopupMenu().setPreferredSize(new Dimension(0, 0)); DefaultKeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(this); addMouseListener(new MouseAdapter() { public void mouseReleased(MouseEvent me) { sendCancelKeyEvent(); } public void mouseClicked(MouseEvent me) { sendCancelKeyEvent(); doAction(); } }); }
Интерфейс KeyEventDispatcher обьявляет единственный метод dispatchKeyEvent в который передается событие клавиатуры. События клавиатуры, которые необходимо обработать – нажатие enter или пробел – вызов метода-обработчика пункта меню, нажатие курсорных клавиш и клавиши esc уже умеет обрабатывать наш суперкласс JMenu.
Поэтому мы обьявим метод с пустой реализацией doAction() который будет переопределяться и в котором будет код, реализующий действия при выборе пункта меню.
public void doAction() { }
После вызова doAction() необходимо снять выделение с пункта меню. Для этого мы генерируем событие, как будто пользователь нажал клавишу esc и передаем его для обработки вызовом processKeyEvent().
protected final void sendCancelKeyEvent() { KeyEvent kee = new KeyEvent((Component)this, 401, 0l, 0, 27, (char)27); dispatchKeyEvent(kee); } public boolean dispatchKeyEvent(KeyEvent e) { KeyEvent kee = new KeyEvent((Component)this, e.getID(), e.getWhen(), e.getModifiers(), e.getKeyCode(),e.getKeyChar()); if (isSelected()) { if (e.getKeyCode() == KeyEvent.VK_ENTER || e.getKeyCode() == KeyEvent.VK_SPACE) { kee = new KeyEvent((Component)this, e.getID(), e.getWhen(), e.getModifiers(), 27, (char)27); doAction(); } } processKeyEvent(kee); return true; }
Чтобы пункт меню так же реагировал на действия мыши регистрируем MouseListener и имплементируем методы mouseReleased() и mouseClicked().
В результате, создав два совсем простых класса мы получили значительное изменение функциональности меню стандартного пользовательского интерфейса.
Полный код классов реализующих такое меню: PictMenu.java и SimplePictMenu.java.
Работающий пример можно увидеть здесь
Оставить комментарий
Комментарии
Попробовал все упаковать в один файл. Вот что получилось.