Это — окончание предыдущей публикации, в которой я рассказывал, как с нуля создавать интерфейс с интерактивной графикой в объектно-ориентированной среде разработки. В первой части мы рассмотрели ключевые архитектурные идеи этой системы: использование композиции, делегирования и шаблонных методов.
Сейчас я перехожу к более рутинным вопросам и рассмотрю построение класса, отвечающего за обработку событий мыши и масштабирование, панорамирование, выделение объектов и drag&drop.
Класс DiagramPanel
Визуальные компоненты
Исходный код класса DiagramPanel, в отличие от DiagramObject, не содержит в себе никаких нетривиальных структурных решений, он лишь выполняет рутинные задачи. Однако его код при этом примерно на 30% длиннее кода DiagramObject. Именно в нём содержится вся специфика, связанная со средой разработки: если вы захотите переписать фреймворк из Java в другую среду (как в своё время мы это сделали, переведя его из Delphi в Java), именно с DiagramObject будут связаны основные трудности.
В случае работы со Swing класс DiagramPanel наследуется от класса javax.swing.JPanel и представляет собой визуальный компонент интерфейса, который может быть помещён на форму приложения. У нашего демо-приложения состоит только из одной этой панели. Структурно DiagramPanel состоит из:
- горизонтальной и вертикальной полос прокрутки javax.swing.JScrollBar (переменные hsb и vsb),
- верхней панели с экранными кнопками,
- заполняющей всё центральное пространство области «для рисования», занятой объектом DiagramPanel.DiagramCanvas, наследником java.awt.Canvas. Класс java.awt.Canvas предполагает создание наследника с переопределённым методом paint(Graphics g), получающим контекст для рисования (Graphics) в качестве аргумента, что мы и делаем в классе DiagramCanvas, вложенном в DiagramPanel. Метод paint(Graphics g) автоматически вызывается, в частности, при изменении размеров окна и изменении перекрытия окон, поэтому диаграмма не «портится» от этих действий.
Отрисовка диаграммы с точки зрения DiagramPanel
Взглянем пристальнее на код метода paint(Graphics g) класса DiagramPanel.DiagramCanvas. Если опустить некоторые детали, он выглядит так:
private static final double SCROLL_FACTOR = 10.0;
public void paint(Graphics g) {
// not ready for painting yet
if (rootDiagramObject == null || g == null)
return;
double worldHeight = rootDiagramObject.getMaxY() - rootDiagramObject.getMinY();
double worldWidth = rootDiagramObject.getMaxX() - rootDiagramObject.getMinX();
int hPageSize = round(canvas.getWidth() / scale);
...
int vPageSize = round(canvas.getHeight() / scale);
...
hsb.setMaximum(round(worldWidth * SCROLL_FACTOR));
vsb.setMaximum(round(worldHeight * SCROLL_FACTOR));
g.clearRect(0, 0, getWidth(), getHeight());
double dX = hsb.getValue() / SCROLL_FACTOR;
double dY = vsb.getValue() / SCROLL_FACTOR;
rootDiagramObject.draw(g, dX, dY, scale);
}
(Код метода упрощён для лучшей читаемости. Реальную реализацию, корректно учитывающую все крайние случаи, можно увидеть в полных исходниках примера статьи.)
Метод выполняет следующее:
- При помощи методов getMaxX/Y корневого объекта-отрисовщика получает ширину и высоту диаграммы в мировых координатах.
- Получает размер видимой области (ширину и высоту) в мировых координатах. Для этого экранная ширина и высота компонента делятся на коэффициент масштабирования (чтобы получить экранную координату из мировой, мы на коэффициент масштабирования умножаем).
- Выставляются максимальные значения и ширины бегунков полос прокрутки, равные соответствующим значениям в мировых координатах, помноженных на коэффициент SCROLL_FACTOR.
Для удобства работы пользователя очень важно, чтобы ширины бегунков полос прокрутки при изменении масштаба картинки (и размеров видимой области) изменялись бы корректно. На этой анимации обратите внимание на изменяющиеся размеры бегунков полос прокрутки и их исчезновение в момент, когда картинка начинает умещаться целиком:
Умножение на SCROLL_FACTOR необходимо потому, что значения, принимаемые полосой прокрутки, являются целочисленными, а мировые координаты, как мы помним, имеют тип double. Соответственно, при большом масштабе увеличения «дискретность» перемещения полос прокрутки может стать слишком заметной и при помощи множителя SCROLL_FACTOR мы, по сути, подменяем их целочисленное значение на значение с фиксированной точкой.
Поскольку метод перерисовки диаграммы корректно учитывает текущий масштаб и положение полос прокрутки, то
- обработка изменения значений полос прокрутки сводится всего лишь к вызову canvas.paint(canvas.getGraphics()) (см. метод scrollBarChange() класса DiagramPanel),
- обработка событий колеса мыши (без нажатой клавиши Ctrl) сводится к
- определению того, к какой из полос прокрутки ближе указатель мыши,
- модификации положения соответствующей полосы прокрутки,
- вызову canvas.paint(canvas.getGraphics()) (см. метод canvasMouseWheel(MouseWheelEvent) класса DiagramPanel).
Масштабирование диаграммы с сохранением заданной неподвижной точки
Изменить масштаб диаграммы в нашем примере пользователь может двумя способами: нажимая на экранные кнопки “Zoom in” и “Zoom out” и при помощи комбинации Ctrl+колесо мыши. В любом случае это приводит к вызову метода setScale(double s) класса DiagramPanel. Но для того, чтобы это выглядело удобно для пользователя, необходимы некоторые хитрости.
Прежде обратим внимание на то, какие значения должен принимать масштаб. Наш давний опыт показывает, что наиболее удобным для пользователя поведением диаграммы является удвоение масштаба за два нажатия на кнопку “Zoom in”. Это значит, что нажатия кнопок “Zoom in”/”Zoom out” должны умножать/делить текущее значение масштаба на квадратный корень из двух (1.41).
Если же пользователю предлагаются значения масштабов из списка, для визуальной равномерности увеличения/уменьшения масштаба они должны быть выбраны из ряда степеней квадратного корня из двух: 50%, 70%, 100%, 140%, 200%.
Неожиданным на первый взгляд может показаться код обработчика события «Ctrl+колесо мыши»:
private static final double WHEEL_FACTOR = 2.8854; // 1/ln(sqrt(2))
setScale(scale * Math.exp(-e.getPreciseWheelRotation() / WHEEL_FACTOR));
Казалось бы, зачем тут экспонента? Обработчик вращения колеса мыши получает количество сделанных пользователем «кликов» колеса (в некоторых случаях выдаются даже дробные части «клика»), и общее правило то же: за два клика картинка должна увеличиваться вдвое. Но это как раз означает, что значения масштаба должны в зависимости от угла поворота колеса изменяться как степени квадратного корня из двух:
поворот колеса | -2 | -1 | 0 | 1 | 2 |
изменение масштаба | 0.5 | 0.7 | 1.0 | 1.4 | 2.0 |
Простая арифметика сводит эти вычисления к экспоненте.
Обратимся теперь к методу setScale(double s) класса DiagramPanel:
if (s < 0.05 || s > 100 || s == scale)
return;
Point p = MouseInfo.getPointerInfo().getLocation();
SwingUtilities.convertPointFromScreen(p, canvas);
double xQuot;
double yQuot;
if (p.x > 0 && p.y > 0 && p.x < canvas.getWidth() && p.y < canvas.getHeight()) {
xQuot = p.getX() / (double) canvas.getWidth();
yQuot = p.getY() / (double) canvas.getHeight();
} else {
xQuot = 0.5;
yQuot = 0.5;
}
int newHVal = hsb.getValue() + round(hsb.getVisibleAmount() * xQuot * (1 - scale / s));
int newVVal = vsb.getValue() + round(vsb.getVisibleAmount() * yQuot * (1 - scale / s));
hsb.setValue(newHVal);
vsb.setValue(newVVal);
scale = s;
canvas.paint(canvas.getGraphics());
Мы видим, что метод, предварительно проверив значение нового масштаба на «разумность»,
- получает позицию курсора мыши,
- если курсор мыши находится над объектом Canvas, то вычисляет его относительную позицию, в противном случае, относительной позицией считается середина Canvas,
- подготавливает новые позиции полос прокрутки,
- и только затем (!) изменяет масштаб и перерисовывает диаграмму.
К чему эти сложности? Всё это делается для того, чтобы во время масштабирования для пользователя визуально осталась неподвижной та точка на диаграмме, на которой в текущий момент времени сфокусирован его взгляд. Если этого не делать, то пользователю придётся «подкручивать» полосы прокрутки после каждого изменения масштаба. Если пользователь изменяет масштаб при помощи экранных кнопок, то неподвижным остаётся центр видимой области диаграммы, если же пользователь делает это при помощи колеса мыши, то неподвижной будет точка, над которой расположен курсор. Выглядит это так:
Режим панорамирования
Наш пример может работать в двух режимах: панорамирования (panning mode) и выделения объектов, переключаемых экранными кнопками. В режиме панорамирования движение мышью с нажатой левой кнопкой приводит к перемещению целиком всей видимой области диаграммы. Наиболее широко известный пример режима панорамирования — это, конечно, соответствующий режим просмотра PDF-файлов в программе Adobe Acrobat Reader.
Движение мышью с нажатой левой кнопкой обрабатывается в методе canvasMouseDragged, и в режиме панорамирования нам достаточно корректировать положение полос прокрутки относительно первоначальной позиции курсора мыши:
if (panningMode) {
hsb.setValue(round((startPoint.x - cursorPos.x / scale) * SCROLL_FACTOR));
vsb.setValue(round((startPoint.y - cursorPos.y / scale) * SCROLL_FACTOR));
}
Уже реализованная машинерия будет перерисовывать картинку корректным образом:
Режим выделения объектов
Логика, связанная с механизмом выделения объектов, стоит несколько особняком, поэтому для удобства она выделена во вложенный класс SelectionManager класса DiagramPanel. В своём поле ArrayList items этот класс хранит все текущие выделенные объекты. Он отвечает за «нащёлкивание» объектов с клавишей Shift, их выделение с помощью «лассо» и перетаскивание. Всё это — довольно сложная как для описания, так и для реализации функциональность. Однако неожиданно быстро разобраться в ней и всё реализовать помогает концепция конечного автомата. (Конечный автомат хотя и не входит в перечень паттернов проектирования GoF и применим лишь для ограниченного класса задач, его удобство и мощь для упрощения некоторых задач заставляют меня относиться к нему как к ещё одному, весьма полезному и стандартизированному паттерну проектирования.)
С точки зрения механизма выделения объектов, всякое движение курсора мыши над диаграммой может происходить в одном из трёх состояний автомата, соответствующих элементам перечисления DiagramPanel.State:
- Левая клавиша мыши не нажата — исходное состояние (SELECTING). Выделение нескольких объектов с нажатой клавишей Shift.
- Левая клавиша мыши нажата — два подслучая:
- перемещается группа выделенных объектов (DRAGGING).
- отрисовывается прямоугольное «лассо» (LASSO).
При реализации класса SelectionManager я набросал на бумаге примерно такую схему состояний и переходов:
Воплотить эту схему в код можно так же, как мы реализуем всякий конечный автомат.
Для отрисовки прямоугольного лассо применяется ещё один наследник DiagramObject – класс DiagramPanel.Lasso. В отличие от других отрисовщиков, он не принадлежит диаграмме и не отрисовывается вместе с ней, а создаётся классом DiagramPanel в момент, когда надо нарисовать выделяющий прямоугольник. Он должен «поспевать» за курсором мыши и поэтому выводится в методе internalDrawSelection с использованием XOR-режима графического контекста.
Следует помнить, что прямоугольник рисуется от своего верхнего левого угла, а начальная точка «лассо» может оказаться в любом углу (см. анимацию), поэтому для отрисовки этого прямоугольника нужна аккуратность, сначала нужно определить ЛВУ:
int x0 = dX > 0 ? startPoint.x : startPoint.x + dX;
int y0 = dY > 0 ? startPoint.y : startPoint.y + dY;
g2.drawRect(x0, y0, Math.abs(dX), Math.abs(dY));
Групповое смещение объектов. Взаимодействие с Undo
Завершив перемещение группы объектов, пользователь отпускает левую кнопку мыши. Что происходит в системе? По идее, достаточно пробежаться по объектам, попавшим в выделение, и «сказать» им, что пользователь их передвинул. Однако не всё так тривиально:
if (commandManager != null)
commandManager.beginMacro("drag & drop");
for (DiagramObject i : items) {
DiagramObject curItem = i.getParent();
while (curItem != null && !items.contains(curItem)) {
curItem = curItem.getParent();
}
if (curItem == null)
i.drop(dX, dY);
}
if (commandManager != null)
commandManager.endMacro();
Как мы помним, объекты у нас бывают «самостоятельные» и «полу-самостоятельные». Если в выделение попал одновременно родительский и зависящий от него полу-самостоятельный объект (например, Actor и подпись к нему), то передвинуть достаточно только родительский объект: зависящий от него «пойдёт за ним следом».
Именно поэтому в цикле мы исключаем зависимые объекты, если они попали в выделение вместе с родительскими, и только для подходящих объектов вызываем метод drop(dX, dY), передающий смещение объекта в экранных координатах в сам объект. DiagramObject пересчитывает «экранные» смещения в «мировые» и вызывает свой виртуальный метод internalDrop, реализация которого на уровне наследников DiagramObject должна отрабатывать событие «перетаскивания мыши», изменяя внутренее состояние объектов модели данных.
А для чего нужны вызовы commandManager.beginMacro/endMacro?
Если в системе реализована функциональность undo/redo, то групповое перетаскивание объектов необходимо обозначить как макрокоманду. Если этого не сделать и пользователь пожелает отменить свою операцию перетаскивания, ему придётся столько раз нажать на кнопку undo(), сколько объектов было за один раз передвинуто. Подробнее обо всём этом можно прочитать в моей статье про реализацию undo и redo.
Заключение
Итак, наш пример готов. При относительно небольшом количестве кода мы получили вполне рабочее решение, со всеми удобствами для пользователя, желающего просматривать и редактировать интерактивную диаграмму, и с полным заделом для встраивания в настоящее приложение, решающее реальные задачи.
Жаль, что времена повального увлечения CASE-средствами давно миновали: с такими умениями мы могли бы создать опасную альтернативу для какого-нибудь Rational Rose :-)
Скачать полный исходный код рассматриваемого в статье примера в формате проекта Maven можно по адресу https://github.com/inponomarev/graphexample.
С помощью этого фреймворка в разные годы мы строили: портфельные матрицы, диаграммы Ганта, диаграммы связей между юридическими лицами, азимутально-частотные диаграммы для радиотехнического оборудования, диаграммы связей между установками.
Обратите внимание, что для использования в других проектах этот код доступен только под лицензией GPL.
Автор признателен создателям системы ShareX, при помощи которой были созданы анимированные GIF-иллюстрации для статьи.
Автор: IvanPonomarev