Юрий Спектор дата публикации 17-04-2008 04:57
Рассмотрим достаточно распространенную практическую задачу: необходимо реализовать визуальный контейнер, на котором размещаются графические объекты. Эти объекты должны быть не только нарисованы на поверхности этого контейнера, но и доступны пользователю для различных манипуляций, таких как выделение, перемещение с помощью мыши и др. Объекты могут быть разнотипными, следовательно, они будут по-разному выглядеть, характеризоваться различными параметрами. Набор операций, которые можно совершать по отношению к ним, также будет различным.
В качестве примеров таких визуальных контейнеров могут служить холст векторного графического редактора, на котором располагаются фигуры-примитивы, всевозможные редакторы схем, диаграмм, средства для отображения и обработки различных численных данных (графики, диаграммы, на которых можно размещать вспомогательные инструменты) и многие другие.
Малоопытные программисты зачастую теряются при попытке реализовать такой визуальный контейнер, и либо не знают, с какой стороны к нему подступиться, либо выбирают нерациональное решение. В данной статье мы попытаемся спроектировать систему, которая будет решать поставленную задачу. Сразу оговорюсь, здесь не будет предоставлено готовое решение на все случаи жизни, которое можно не задумываясь переносить в свой проект. Здесь будет лишь показан один из вариантов того, как нужно действовать для решения подобных задач. А именно, мы попытаемся решить эту задачу на примере простого редактора блок-схем.
Рисунок 1. Пример контейнера визуальных объектов - редактор блок-схем.
Данная статья предназначена для программистов, не имеющих большого опыта в проектировании объектно-ориентированных систем. Большое внимание будет уделено не столько самому решению поставленной задачи, сколько рассмотрению того, с какой стороны к этому решению подходить, на какие этапы его разбивать, как рассуждать в той или ситуации и какие из возможных путей избрать, чтобы читатель в будущем смог самостоятельно эффективно решать поставленные перед ним задачи проектирования.
Итак, определимся с введенными ранее понятиями, а также дополним их некоторыми новыми. Контейнером будем называть холст, на котором объекты размещаются. Как уже было сказано выше, примером контейнера могут служить холсты различных программ-редакторов, например - редактор блок-схем или схем электрических. В качестве контейнера можно даже рассматривать поля для некоторых игр, например - шахматная доска.
Контейнер является вместилищем объектов. Объекты могут быть разнотипными, например, для программы редактора блок-схем, объектами будут выступать фигуры, составляющие узлы блок-схем и соединительные линии. Объект воспринимается пользователем как самостоятельный элемент, и пользователь может осуществлять с ним некоторый набор операций. Например, растягивать и перемещать с помощью мыши.
Положение объекта на контейнере характеризуется координатами. Введем два понятия — логические координаты и экранные координаты.
Логическими координатами условимся называть координаты объекта или его составных частей (вершины, например) в условной координатной системе контейнера. Например, если контейнер представляет собой шахматную доску, его логический размер будет 8x8 и каждый объект (фигура) будет характеризоваться положением согласно этой координатной системе. Если у нас контейнер - это холст для редактирования блок-схем, то логический размер холста можно задать более произвольно. Например, мы можем принять его равным 200x100 некоторых абстрактных единиц. А можно принять размер в соответствии с физическим размером листа бумаги данного формата в миллиметрах или дюймах. Например, для листа A4 мы можем принять размер 210x297 (размер листа в миллиметрах). Таким образом, каждый объект на контейнере будет характеризоваться определенными координатами, значения которых не зависят от выбранного пользователем масштаба, области просмотра, разрешения экрана и т.д. Напротив, экранными координатами объекта будем называть его смещение в пикселях относительно начала координат компонента, представляющего контейнер. Экранные координаты нам понадобятся при выводе объекта на канве контейнера, логическими же мы будем оперировать во всех остальных случаях, чтобы отделить логику работы системы от отображения на экране. Подобная архитектура, где логика отделена от представления на экране, носит название "паттерн Модель-вид". Очень часто фигурирует еще и третье понятие - "контроллер" - абстрактный слой, соединяющий между собой логику и отображение. Такая архитектура носит название "Модель-вид-контроллер".
Приведем список объектов, которые нам понадобятся для составления блок-схем, опишем их параметры, внешний вид и действия, которые над ними можно совершать.
- Блок "начало/конец" - прямоугольник с закругленными краями
- Блок "действие" - прямоугольник
- Блок "ввод/вывод" - параллелограмм
- Блок "условие" - ромб, поставленный на угол
- Блок "вызов подпрограммы" - прямоугольник с двумя вертикальными линиями по краям.
- Блок "надпись" - просто текст
- Блок "соединительная линия" - простая линия, соединяющая две точки
Вокруг всех блоков, кроме последнего, можно мысленно описать габаритный прямоугольник. Взявшись за углы этого прямоугольника мышью, мы сможем изменять его размеры по двум координатам сразу, взявшись за стороны - по одной координате. Если мы схватимся мышью за внутреннюю часть блока, мы сможем перемещать его полностью. С соединительной линией ситуация не сильно отличается. Взявшись за ее концы, мы сможем перемещать их по отдельности, за саму линию - переместим ее целиком.
Получается, что положение каждого объекта характеризуется двумя парами координат - верхним левым и нижним правым углом описанного прямоугольника (для всех, кроме линий) или координатами концов (для соединительной линии). Чтобы это нам не упрощало задачу, введем еще один блок, у которого количество вершин может быть произвольным:
И набор операций для него будет несколько отличаться. Взявшись за вершины, мы также сможем перемещать их независимо, взявшись за любую линию - мы сможем перемещать объект полностью. Двойной щелчок на линии - добавит в этом месте вершину, а двойной щелчок на вершине - удалит ее, но только в том случае, если количество вершин после удаления не станет меньше двух. В противном случае - ничего удалять не будем.
Объекты в контейнере необходимо также конструировать в позиции, определяемой пользователем. Конструирование всех блоков, кроме "ломаной линии", будет заключаться в последовательном нажатии левой кнопки мыши в двух точках, при этом между этими нажатиями объект уже будет отображаться и "тянуться" за мышью. Ломаная будет конструироваться подобным образом - каждый следующий щелчок мыши будет добавлять вершину в позиции щелчка. Последняя вершина будет также "тянуться" за мышью. Щелчок правой кнопкой будет означать окончание построения.
Кроме координат, все блоки, кроме соединительной и ломаной линии, смогут содержать некоторый текст. Следовательно, к этим блокам добавятся еще один параметр. Для простоты, мы не будем вводить параметр шрифта, но если в этом будет необходимость - реализовать это не составит труда. Также мы для простоты опустим другие настройки внешнего вида, такие как цвет, заливка и прочие.
Итак, подытожим все, что мы определили для объектов.
Параметры:
- Список вершин в логических координатах
- Текст (кроме линии и ломаной линии)
Операции:
- Растягивание за вершину описанного прямоугольника
- Растягивание за сторону описанного прямоугольника
- Перемещение объекта за его внутреннюю область
- Добавление вершины по двойному щелчку на линии (только для ломаной линии)
- Удаление вершины по двойному щелчку на ней (только для ломаной линии)
- Конструирование объекта в указанной позиции
Можно было бы ввести и еще некоторые специфичные для объекта операции, такие как управление углом наклона параллелограмма или радиусом закругления блока "начало/конец", однако добавить новые операции не составляет никакого труда и мы, чтобы не загромождать пример, не станем этого делать.
Каждый объект должен уметь нарисовать себя. При этом не забудем, что объект ничего не знает об экранных координатах, а оперирует только логическими. Объект должен содержать универсальный код, позволяющий вывести себя на поверхность любого компонента, который будет выступать для него в качестве контейнера.
Теперь поговорим о параметрах контейнера. Прежде всего, зададимся его логическими размерами. Пусть это будет 210 логических единиц по горизонтали и 297 - по вертикали. Т.е. это размеры вертикально расположенного листа формата A4 в миллиметрах. С помощью мыши мы сможем переместить вершины объекта только в узлы некоторой сетки. Для простоты, примем этот шаг равный одной логической единице. Шаг сетки можно было бы сделать и произвольным, задаваемым пользователем, а можно было бы и не делать сетку вовсе, однако ее наличие упростит выравнивание объектов на контейнере. Объекты ничего не будут знать о сетке, их координаты просто будут задаваться в вещественных числах, которые представляют собой логические координаты. Шаг сетки будет учитываться только при переходе от экранных координат к логическим - при переводи просто будем округлять результат с учетом этого шага.
Контейнер также будет позволять производить над объектами некоторые операции, но эти операции скорее будут иметь отношение к самому контейнеру, чем к объектам:
- Выделение объекта при щелчке на нем мышью.
- Удаление выделенного объекта по нажатию на кнопку Delete.
Кроме того, у контейнера можно задать масштаб. При масштабе 100% одной логической единице будет соответствовать один миллиметр.
Для решения любой сколько-нибудь сложной задачи, ее нужно разбить на более простые части. Потом эти части можно разбить на еще более мелкие части и т.д. Правильно разбить задачу на подзадачи - очень важный этап, не сделав этого, эффективное решение найти крайне проблематично. Именно поэтому, прежде чем садиться за клавиатуру, очень полезно провести некоторое время с карандашом и бумажкой.
Если свалить все в кучу, то реализация нашего контейнера будет представлять собой эдакого неповоротливого монстра, содержащего огромное количество кода, разобраться в котором даже самому программисту, написавшему его, порою не так то просто. При этом если в будущем понадобится расширять систему, добавляя новые объекты или операции над ними, весь этот код придется пересматривать и "перелопачивать". Не самое приятное занятие, чревато занесением кучи ошибок. Рано или поздно этот "монстр" рухнет под своим собственным весом. Так что ни о какой гибкости и масштабируемости построенной таким образом системы говорить не приходится.
Для эффективного решения, целесообразно разбить систему на некие абстрактные блоки, реализующие строго свою часть задачи и определенным образом взаимодействующие между собой. В данном случае, разумно было бы спроектировать контейнер таким образом, чтобы он мог однообразно работать с любыми объектами, не задумываясь об их конкретном типе. А всю специфику поведения конкретных объектов переложить на сами эти объекты. Таким образом, код контейнера станет полиморфным - т.е. обобщенный код, оперирующий некими абстрактными понятиями, конкретное поведение которого зависит от конкретных типов объектов, для которого он выполняется. Написанный однажды, такой код будет работать с любым типом объектов, главное чтобы эти объекты выполняли некоторые предъявляемые к ним требования.
Итак, реализацию поставленной задачи можно разбить на следующие части:
- Реализация визуальных объектов, их логики работы, без привязки к какому-либо конкретному контейнеру. Общий интерфейс, через который контейнер будет взаимодействовать с объектами, нужно вынести в базовый класс, от которого все объекты будут унаследованы.
- Реализация контейнера, который будет содержать, отображать и управлять визуальными объектами однообразно, не зависимо от их конкретного типа, используя интерфейс базового для объектов класса.
Подобным разбиением, мы отделяем логику от вида, что позволяет нам реализовать каждую часть независимо, не смешивая все в кучу. Кстати, спроектированную таким образом модель можно отнести к типу "Клиент-сервер". Одной из особенностей и преимуществ такой модели является как раз то, что клиента нисколько не волнует особенности реализации сервера и наоборот. Главное, чтобы между клиентом и сервером был согласован интерфейс (или протокол) взаимодействия. Объекты будут выступать своего рода сервером, который принимает и обрабатывает запросы клиента - контейнера. Взаимодействие осуществляется через строго определенный интерфейс. Контейнер же будет просто хранить список объектов, управлять им, а также в зависимости от необходимого действия - отправлять им различные команды-запросы. Именно контейнер будет выступать своеобразным посредником между объектами и пользователем - принимать от пользователя команды, перенаправлять их объектам, и отображать результат. Таким образом, являясь клиентом для объектов, контейнер одновременно является как бы сервером для пользовательских запросов.
Ну что ж, задачу мы поставили, с основными этапами ее реализации определились, теперь можем двигаться дальше. Опишем базовый класс для всех объектов, в который включим:
- Механизмы задания и управления позицией объекта.
- Механизмы обработки операций.
- Механизм прорисовки объекта на некой логической поверхности.
- Возможность объекта сообщать клиентскому коду, принадлежит ли точка (X; Y) ему, и если да, то какой именно его части - вершине, стороне или внутренней области. Принадлежность точки определяется с учетом заданной погрешности. Эта возможность нужна для того, чтобы контейнер мог определить, над какой частью объекта находится мышь, так как от этого зависит тип выполняемой операции.
- Механизмы конструирования объекта пользователем в указанной позиции.
Теперь по порядку реализуем это в базовом классе.
Сразу введем для себя еще два понятия. Базовыми точками будем называть минимальное количество точек, однозначно определяющих позицию объекта. Например, для объекта имеющего прямоугольную форму базовых точек будет две - левый верхний угол и правый нижний угол. Вершинами будем называть особые точки объекта, по отношению к которым пользователь может осуществлять операции. У того же объекта прямоугольной формы вершин будет четыре - за каждый угол прямоугольника пользователь сможет растягивать объект.
И базовые точки, и вершины объекта хранят координаты в логических единицах, а конкретная точка или вершина идентифицируется по индексу. Внутренне, объект будет хранить свою позицию в виде списка базовых точек, так как этого вполне достаточно для однозначного определения позиции. Но извне базовые точки будут недоступны. Вместо этого объект будет предоставлять доступ к своим вершинам. Список вершин в объекте специально хранить нет смысла, достаточно "научить" объект возвращать координаты указанной вершины, имея на руках только координаты базовых точек.
Для задания логических координат точек введем тип TFloatPoint.
type
PFloatPoint = ^TFloatPoint;
TFloatPoint = record
X, Y: Extended;
end;
Список базовых точек реализуем с помощью класса TList, в котором будем хранить указатели на координаты. Память для этих координат будем выделять и освобождать динамически.
TBaseVisualObject = class(TObject)
private
FBasePoints: TList;
FOnChange: TNotifyEvent;
FLockCount: Integer;
function GetBasePointsCount: Integer;
function GetBasePoint(Index: Integer): TFloatPoint;
procedure SetBasePoint(Index: Integer; const Value: TFloatPoint);
protected
procedure Change;
procedure AddBasePoint(X, Y: Extended);
procedure InsertBasePoint(Index: Integer; X, Y: Extended);
procedure DeleteBasePoint(Index: Integer);
procedure ClearBasePoints;
property BasePointsCount: Integer read GetBasePointsCount;
property BasePoints[Index: Integer]: TFloatPoint read GetBasePoint
write SetBasePoint;
function GetVertexesCount: Integer; virtual; abstract;
function GetVertex(Index: Integer): TFloatPoint; virtual; abstract;
procedure SetVertex(Index: Integer; const Value: TFloatPoint); virtual; abstract;
public
constructor Create;
destructor Destroy; override;
procedure BeginUpdate;
procedure EndUpdate;
property VertexesCount: Integer read GetVertexesCount;
property Vertex[Index: Integer]: TFloatPoint read GetVertex write SetVertex;
property OnChange: TNotifyEvent read FOnChange write FOnChange;
end;
...
implementation
procedure TBaseVisualObject.AddBasePoint(X, Y: Extended);
var
NewBasePoint: PFloatPoint;
begin
New(NewBasePoint);
NewBasePoint^.X := X;
NewBasePoint^.Y := Y;
FBasePoints.Add(NewBasePoint);
Change;
end;
procedure TBaseVisualObject.BeginUpdate;
begin
Inc(FLockCount);
end;
procedure TBaseVisualObject.Change;
begin
if Assigned(FOnChange) and (FLockCount = 0) then
FOnChange(Self);
end;
procedure TBaseVisualObject.ClearBasePoints;
var
i: Integer;
begin
for i := 0 to FBasePoints.Count - 1 do
Dispose(PFloatPoint(FBasePoints[i]));
FBasePoints.Clear;
Change;
end;
constructor TBaseVisualObject.Create;
begin
inherited Create;
FBasePoints := TList.Create;
end;
procedure TBaseVisualObject.DeleteBasePoint(Index: Integer);
begin
Dispose(PFloatPoint(FBasePoints[Index]));
FBasePoints.Delete(Index);
Change;
end;
destructor TBaseVisualObject.Destroy;
var
i: Integer;
begin
for i := 0 to FBasePoints.Count - 1 do
Dispose(PFloatPoint(FBasePoints[i]));
FBasePoints.Free;
inherited Destroy;
end;
procedure TBaseVisualObject.EndUpdate;
begin
FLockCount := Max(0, FLockCount - 1);
if FLockCount = 0 then
Change;
end;
function TBaseVisualObject.GetBasePoint(Index: Integer): TFloatPoint;
begin
Result := PFloatPoint(FBasePoints[Index])^;
end;
function TBaseVisualObject.GetBasePointsCount: Integer;
begin
Result := FBasePoints.Count;
end;
procedure TBaseVisualObject.InsertBasePoint(Index: Integer; X, Y: Extended);
var
NewBasePoint: PFloatPoint;
begin
New(NewBasePoint);
NewBasePoint^.X := X;
NewBasePoint^.Y := Y;
FBasePoints.Insert(Index, NewBasePoint);
Change;
end;
procedure TBaseVisualObject.SetBasePoint(Index: Integer;
const Value: TFloatPoint);
begin
PFloatPoint(FBasePoints[Index])^ := Value;
Change;
end;
Как уже было сказано выше, управление базовыми точками клиентскому коду недоступно. Базовые точки - это "внутренняя кухня" объектов, мы будем использовать их при реализации конкретных потомков класса, например, создавая объект прямоугольной формы, в конструкторе класса мы сразу же добавим две базовые точки, которые и будут управлять позицией объекта. В объекте "ломаная линия" мы сможем добавлять и удалять точки произвольно. Кстати, в объекте "линия" и "ломаная линия" базовые точки будут полностью эквивалентны вершинам.
Управление вершинами осуществляется через свойство Vertex[Index], причем методы получения/установки свойства созданы виртуальными и абстрактными. В зависимости от типа объекта, мы напишем соответствующую реализацию этих методов, которые будут связывать между собой вершины с базовыми точками.
Также стоит обратить внимание на метод Change. Этот метод вызывается при любом изменении состояния объекта. В нем проверяется, назначен ли обработчик события OnChange, и если да, и при этом не было блокировок - то вызывает этот обработчик. Его можно использовать для того, чтобы клиентский код мог отреагировать на изменение объектов (например, вызвать перерисовку объектов с учетом измененных параметров). Методы блокировок BeginUpdate и EndUpdate будем использовать тогда, когда нужно временно запретить вызывать обработчик OnChange, в частности, когда изменений состояния объекта планируется много, но при этом нет смысла каждый раз вызывать OnChange - достаточно одного вызова в конце. Вызов BeginUpdate увеличивает на 1 счетчик блокировок, а EndUpdate - соответственно уменьшает, при этом, не давая ему принять отрицательное значение - т.н. "защита от дурака". Если в результате снятия блокировки счетчик стал равен 0 (все блокировки сняты), то вызывается метод Change чтобы позволить вызывающему коду отреагировать на изменения.
Для осуществления операций над объектами, необходимо каким-либо образом отправить ему команду, которая будет содержать код конкретной операции, а также некоторый набор параметров. Объект, получив эту команду, соответствующим образом на нее отреагирует. Очень напоминает механизм, по которому окна в Window получают и обрабатывает сообщения. Было бы удобно описать методы с директивой message, для обработки каждого конкретного типа операции, а потом просто отправлять объекту сообщения. К тому же, для добавления новых типов команд не пришлось бы сильно переделывать всю архитектуру, достаточно было бы у нужных классов добавить обработчик нового сообщения. Однако наши объекты не являются окнами Windows. Как быть?
Ответ заключается в том, что в Delphi методы с директивой message можно описывать у любых классов, не обязательно у оконных элементов управления. Метод Dispatch, который позволяет находить и вызывать такие методы, реализован в классе TObject. Нам остается только добавить в класс метод, назвав его, скажем, SendCommand, принимающий идентификатор и параметры сообщения, а в реализации этого метода просто вызывать Dispatch. Теперь вызов SendCommand будет приводить к вызову метода, описанного директивой message, соответствующего идентификатору отправляемого сообщения. Сделаем это.
TBaseVisualObject = class(TObject)
...
public
...
function SendCommand(ID: Cardinal; wParam, lParam: Longint): Longint;
...
end;
implementation
...
function TBaseVisualObject.SendCommand(ID: Cardinal; wParam,
lParam: Longint): Longint;
var
Message: TMessage;
begin
Message.Msg := ID;
Message.WParam := wParam;
Message.LParam := lParam;
Dispatch(Message);
Result := Message.Result;
end;
Некоторые операции и их обработку можно определить еще в базовом классе. В потомках, при необходимости, мы всегда сможем перекрыть это поведение своим. В базовом же классе определим следующие операции:
- Начало перемещения или растягивания объекта с помощью мыши
- Завершение перемещения или растягивания объекта с помощью мыши
- Перемещение или растягивание объекта с помощью мыши
- Перенос вершины с заданным индексом в указанную позицию
- Перенос стороны с заданным индексом в указанную позицию
- Перенос объекта целиком на указанное смещение
При получении первой команды из списка будем переводить объект в соответствующий режим и сохранять стартовые параметры. При получении второй команды - выводить объект из этого режима. Смысл третьей операции будет сводиться к тому, чтобы определить, за какую часть объекта тянет пользователь (вершину, сторону, внутреннюю область) и соответственно оправлять более конкретную команду из трех последних в списке.
Прежде, чем объявить типы сообщений и написать реализацию их обработчиков, определимся, как мы будем отличать конкретные области объектов, такие как вершины, стороны, внутренняя область. Для идентификации области будем использовать тип Cardinal - 4-хбайтное беззнаковое целое. Отдельные биты этого числа будут кодировать тип области (вершина, сторона и т.д.) и ее индекс.
const
HT_OUT = $00000000;
HT_IN = $80000000;
HT_VERTEX = $40000000;
HT_SIDE = $20000000;
Индекс вершины и стороны будет прибавляться соответственно к константам HT_VERTEX и HT_SIDE. Теперь определимся с типами и параметрами сообщений.
const
...
VOC_BEGINDRAG = 1;
VOC_ENDDRAG = 2;
VOC_DRAG = 3;
VOC_VERTEXMOVE = 4;
VOC_SIDEMOVE = 5;
VOC_MOVE = 6;
type
...
TVOCBeginDrag = packed record
CmdID: Cardinal;
HitTest: Cardinal;
StartPos: PFloatPoint;
Result: Longint;
end;
TVOCEndDrag = packed record
CmdID: Cardinal;
Unused1: Longint;
Unused2: Longint;
Result: Longint;
end;
TVOCDrag = packed record
CmdID: Cardinal;
Unused: Longint;
NewPos: PFloatPoint;
Result: Longint;
end;
TVOCVertexMove = packed record
CmdID: Cardinal;
Index: Integer;
NewPos: PFloatPoint;
Result: Longint;
end;
TVOCSideMove = packed record
CmdID: Cardinal;
Index: Integer;
NewPos: PFloatPoint;
Result: Longint;
end;
TVOCMove = packed record
CmdID: Cardinal;
DeltaX: PExtended;
DeltaY: PExtended;
Result: Longint;
end;
Реализуем в базовом классе обработчики этих команд по умолчанию. Метод обработки VOC_SIDEMOVE в базовом классе реализовывать не будем, поведение объектов при получении этой команды определим у потомков.
type
...
TBaseVisualObject = class(TObject)
private
...
FDragging: Boolean;
FDragHitTest: Cardinal;
FDragStartPos: TFloatPoint;
...
procedure VOCBeginDrag(var Command: TVOCBeginDrag); message VOC_BEGINDRAG;
procedure VOCEndDrag(var Command: TVOCEndDrag); message VOC_ENDDRAG;
procedure VOCDrag(var Command: TVOCDrag); message VOC_DRAG;
procedure VOCVertexMove(var Command: TVOCVertexMove); message VOC_VERTEXMOVE;
procedure VOCMove(var Command: TVOCMove); message VOC_MOVE;
...
implementation
...
procedure TBaseVisualObject.VOCBeginDrag(var Command: TVOCBeginDrag);
begin
FDragging := True;
FDragHitTest := Command.HitTest;
FDragStartPos := Command.StartPos^;
end;
procedure TBaseVisualObject.VOCDrag(var Command: TVOCDrag);
var
HitTest: Cardinal;
Index: Integer;
DeltaX, DeltaY: Extended;
begin
if FDragging then begin
HitTest := FDragHitTest and $FFFF0000;
Index := FDragHitTest and $0000FFFF;
case HitTest of
HT_IN:
begin
DeltaX := Command.NewPos^.X - FDragStartPos.X;
DeltaY := Command.NewPos^.Y - FDragStartPos.Y;
FDragStartPos := Command.NewPos^;
SendCommand(VOC_MOVE, Longint(@DeltaX), Longint(@DeltaY));
end;
HT_VERTEX: SendCommand(VOC_VERTEXMOVE, Index, Longint(Command.NewPos));
HT_SIDE: SendCommand(VOC_SIDEMOVE, Index, Longint(Command.NewPos));
end;
end;
end;
procedure TBaseVisualObject.VOCEndDrag(var Command: TVOCEndDrag);
begin
FDragging := False;
end;
procedure TBaseVisualObject.VOCMove(var Command: TVOCMove);
var
i: Integer;
Pos: TFloatPoint;
begin
BeginUpdate;
try
for i := 0 to BasePointsCount - 1 do begin
Pos := BasePoints[i];
Pos.X := Pos.X + Command.DeltaX^;
Pos.Y := Pos.Y + Command.DeltaY^;
BasePoints[i] := Pos;
end;
finally
EndUpdate;
end;
end;
procedure TBaseVisualObject.VOCVertexMove(var Command: TVOCVertexMove);
begin
Vertex[Command.Index] := Command.NewPos^;
end;
Каждый объект должен уметь нарисовать себя. Казалось бы, простая задача - ввести в базовом классе виртуальный метод Draw, который и будет рисовать объект на переданной ему в качестве параметра канве TCanvas. Но не все так просто. Объект знает о своей позиции, но только в логических единицах, которые для TCanvas не имеют никакого смысла. Конечно, можно в этот метод передавать еще и необходимые коэффициенты для перевода из одной системы координат в другие, но мы сделаем иначе.
Реализуем класс TLogicalCanvas, который будет представлять собой что-то вроде моста из логической системы координат в экранные. Объект будет рисовать себя, вызывая методы TLogicalCanvas, которые в качестве параметров принимают логические координаты, а уже внутри этих методов будет идти трансляция и прорисовка на TCanvas. Это добавит нашей системе дополнительную гибкость. Например, мы можем в классе TLogicalCanvas реализовать методы рисования виртуальными, объявить у класса потомки и там их перекрыть. Например, в одном потомке будут рисоваться простые линии и фигуры на двумерном холсте. В другом потомке - они могут быть изображены, например, в трехмерном пространстве. Именно для редактора блок-схем, такое поведение, разумеется, не нужно, но под понятие "контейнер визуальных объектов" попадает достаточно много задач, а в некоторых из них это может понадобиться. Зато в нашем случае, мы сможем не перекладывать на объект полностью ответственность за свой вид, а лишь сказать ему, из каких составных частей он состоит. Сами эти составные части могут выглядеть иначе, и мы сможем гибко этим управлять, например, нам не составит труда в одном случае обозначать у объекта вершины квадратами, в другом - кругами, а в третьем - и вовсе не обозначать, а строить объект только из линий. При добавлении нового способа отображения, менять код самого объекта не придется. Снова модель и вид отделены друг от друга. Однако реализовывать потомки TLogicalCanvas мы не станем, а данный класс введем чисто для демонстрации описанного выше подхода.
Как уже было сказано выше, класс TLogicalCanvas должен уметь переходить из одной системы координат в другую. Однако закладывать эту возможность в сам класс смысла мало. Дело в том, что перевод из одной системы координат в другую может понадобится не только для рисования объектов, но и, скажем, при определении области объекта под курсором мыши. Очевидно, что код преобразования координат в этих случаях должен быть идентичным и его нет смысла дублировать. Выходит, что TLogicalCanvas должен обращаться к какому-либо внешнему объекту (предположительно - контейнеру) для того, чтобы вызвать его методы преобразования координат. Не совсем красивое решение - некий абстрактный класс, представляющий собой холст, должен что-то знать о конкретном классе, который такие преобразования может осуществлять. К тому же, нам не нужна вся функциональность этого конкретного класса, а только лишь методы преобразования координат.
Тут следует вспомнить об одной возможности языка, которая в данном случае может пригодиться - интерфейсы. Интерфейс представляет собой простое описание методов, реализацию которых должен взять на себя класс. Когда методы интерфейса в классе реализованы (не важно в каком), мы можем привести экземпляр этого класса к интерфейсному типу и передать полученную интерфейсную ссылку туда, где эти методы могут понадобиться. Зная только описание интерфейса, можно вызывать его методы, при этом, совершенно не задумываясь о прочих кроме самого интерфейса сущностях. Очень удобно получается - класс TLogicalCanvas не знает, кто именно и каким образом реализует методы преобразования координат, но ему предоставлена возможность эти методы вызывать.
Объявим интерфейс, в котором определим методы перевода из логических единиц в экранные и наоборот.
type
ICoordConvert = interface(IInterface)
procedure LogToScreen(lX, lY: Extended; var sX, sY: Integer); overload;
procedure ScreenToLog(sX, sY: Integer; var lX, lY: Extended); overload;
function LogToScreen(Value: Extended): Integer; overload;
function ScreenToLog(Value: Integer): Extended; overload;
end;
Тут следует пояснить. Мы определили две пары методов с одинаковыми названиями, но разным набором параметров. Первая пара методов принимает в качестве параметров координаты точки в одной системе и возвращает в другой. Будучи реализованы в контейнере, эти методы будут сильно зависеть от его состояния - выбранного масштаба, видимой в компоненте области, которая задается положением скроллбаров. Вторая же пара методов, по сути, зависит только от выбранного масштаба. Эти методы будут возвращать не абсолютные координаты точки, а просто переводить (в нашем случае) пиксели в миллиметры и наоборот. Нам пригодятся обе пары методов.
При создании экземпляра TLogicalCanvas будем передавать ему параметр типа ICoordConvert, с помощью которого и будут осуществляться координатные преобразования.
Для примера покажем, как в этом классе будет реализован метод, рисующий отрезок. Реализация рисования других примитивов принципиально ничем не отличается.
type
TLogicalCanvas = class(TObject)
private
FCanvas: TCanvas;
FConvertIntf: ICoordConvert;
public
constructor Create(Canvas: TCanvas; ConvertIntf: ICoordConvert);
procedure DrawLine(X1, Y1, X2, Y2, LineWidth: Extended);
...
end;
implementation
constructor TLogicalCanvas.Create(Canvas: TCanvas;
ConvertIntf: ICoordConvert);
begin
inherited Create;
FCanvas := Canvas;
FConvertIntf := ConvertIntf;
end;
procedure TLogicalCanvas.DrawLine(X1, Y1, X2, Y2, LineWidth: Extended);
var
sX1, sY1, sX2, sY2: Integer;
begin
FConvertIntf.LogToScreen(X1, Y1, sX1, sY1);
FConvertIntf.LogToScreen(X2, Y2, sX2, sY2);
FCanvas.Pen.Width := FConvertIntf.LogToScreen(LineWidth);
FCanvas.MoveTo(sX1, sY1);
FCanvas.LineTo(sX2, sY2);
end;
Тут все достаточно просто и очевидно, в подробных комментариях нет нужды. В методы рисования передаются параметры в логических единицах, в этих методах осуществляется переход в экранные координаты и вывод на канву, которую мы указали в конструкторе.
Теперь объявим у базового класса объектов виртуальный метод Draw, принимающий логическую канву в качестве параметра. В потомках этот метод мы перекроем, а в базовом классе определим общую реализацию - рисование всех вершин.
TBaseVisualObject = class(TObject)
...
public
...
procedure Draw(Canvas: TLogicalCanvas); virtual;
...
implementation
...
procedure TBaseVisualObject.Draw(Canvas: TLogicalCanvas);
var
i: Integer;
begin
for i := 0 to VertexesCount - 1 do
Canvas.DrawVertex(Vertex[i].X, Vertex[i].Y);
end;
Хорошо, а как клиентский код определит, какая область объекта находится под указателем мыши? Объект должен иметь метод, принимающий координаты в качестве параметров, и возвращающий код области. Причем нужно учесть, что пользователю весьма проблематично подвести курсор мыши прямо на вершину, и поэтому некоторые ее окрестности также должны восприниматься, как вершина. То же касается и сторон. И снова, необходимо учесть, что координаты мыши задаются в экранных единицах, а объект знает свое положение только в физических координатах. Значит, для этого действия нам снова придется использовать ранее введенный интерфейс.
Данную операцию также можно реализовать в качестве команды. Ее обработчик в базовом классе будет просто возвращать HT_OUT, а уже в конкретных потомках мы перекроем эту операцию, чтобы объект правильно сообщал свои области в точках.
Кроме того, при действиях над объектом с помощью мыши, в зависимости от того, над какой его частью находятся мышь, необходимо соответствующим образом менять вид курсора. Можно ввести еще одну команду, в которую передается код области в качестве параметра, а результатом возвращается идентификатор курсора. Для идентификации курсора в Delphi есть специальный тип - TCursor. Однако мы не будем однозначно "зашивать" в объекты вид курсора, логично было бы оставить эту возможность использующему объект контейнеру. В объекте мы будем формировать свой независимый код курсора, а уж контейнер пусть делает с ним что захочет. Например, принимает различную форму курсора для разных кодов, или один общий вид курсора (скажем, в форме руки) для всех.
const
...
CR_DEFAULT = 0;
CR_SIZEALL = 1;
CR_HORIZONTAL = 2;
CR_VERTICAL = 3;
CR_DIAG1 = 4;
CR_DIAG2 = 5;
...
VOC_HITTEST = 7;
VOC_GETCURSOR = 8;
type
PHitTestParams = ^THitTestParams;
THitTestParams = record
XPos, YPos: Integer;
Tolerance: Integer;
end;
...
TVOCHitTest = packed record
CmdID: Cardinal;
ConvertIntf: ICoordConvert;
Params: PHitTestParams;
Result: Cardinal;
end;
TVOCGetCursor = packed record
CmdID: Cardinal;
Unused: Longint;
HitTest: Cardinal;
Result: Cardinal;
end;
TBaseVisualObject = class(TObject)
private
...
procedure VOCHitTest(var Command: TVOCHitTest); message VOC_HITTEST;
...
implementation
...
procedure TBaseVisualObject.VOCGetCursor(var Command: TVOCGetCursor);
begin
Command.Result := CR_DEFAULT;
end;
procedure TBaseVisualObject.VOCHitTest(var Command: TVOCHitTest);
begin
Command.Result := HT_OUT;
end;
Для конструирования объектов также имеет смысл определить несколько новых команд:
- VOC_CONSTRUCTPOINT
- - будем вызывать при щелчке левой кнопкой мыши в режиме конструирования. Обработка данной команды будет фиксировать очередную точку объекта. Причем, если результатом команда вернет ноль, это будет признаком того, что конструирование объекта закончено.
- VOC_PROCESSCONSTRUCT
- - эту команду будем отправлять в режиме конструирования при перемещении мыши между нажатиями. Она нужна для того, чтобы обновить позицию объекта с учетом текущего положения курсора.
- VOC_STOPCONSTRUCT
- - будем вызывать при нажатии правой кнопки мыши в режиме конструирования для того, чтобы принудительно его завершить. При этом если результатом команда вернет ноль, это будет означать, что конструирование завершено слишком рано, позиция объекта не задана и его следует уничтожить. Эта возможность поможет нам отменять создание любого объекта по нажатию правой кнопкой мыши. Для ломаной полностью отменить конструирование можно будет только после установки первой вершины, щелчок правой кнопкой после установки последующих вершин просто завершит конструирование.
const
...
VOC_PROCESSCONSTRUCT = 10;
VOC_STOPCONSTRUCT = 11;
...
type
...
TVOCConstructPoint = packed record
CmdID: Cardinal;
Unused: Longint;
Pos: PFloatPoint;
Result: Longint;
end;
TVOCProcessConstruct = packed record
CmdID: Cardinal;
Unused: Longint;
Pos: PFloatPoint;
Result: Longint;
end;
TVOCStopConstruct = packed record
CmdID: Cardinal;
Unused1: Longint;
Unused2: Longint;
Result: Longint;
end;
Процесс конструирования будет несколько отличаться для разных типов объектов, поэтому с реализацией обработчиков этих команд - повременим.
Похоже, что с базовым классом мы полностью определились. Пришло время заняться его потомками. Для начала вспомним, какие объекты мы решили реализовать. Можно заметить, что эти объекты можно разделить на две группы:
- Объекты, положение которых задается описанным прямоугольником.
- Объекты, состоящие из линий ("соединительная линия", "ломаная линия").
В связи с этим, целесообразно ввести еще два базовых класса, в которые заложить общее поведение для объектов данной группы.
Прежде чем приступить к реализации базового для "прямоугольных" объектов класса, подумаем, что в него можно включить.
- Создание фиксированного количества базовых точек (две точки - можно создать в конструкторе)
- Задать соответствие между базовыми точками и вершинами. Вершины условимся нумеровать от нуля до трех, начиная с верхнего левого угла, с обходом по часовой стрелке. Стороны - тоже от нуля до трех, начиная с левой, с обходом по часовой стрелке.
- Обработка команд VOC_CONSTRUCTPOINT, VOC_PROCESSCONSTRUCT, VOC_STOPCONSTRUCT, VOC_SIDEMOVE, VOC_HITTEST, VOC_GETCURSOR.
- Свойство Text: String;
Т.е. по сути, потомкам данного класса останется только переопределить собственную реализацию метода Draw.
Зададим соответствие между вершинами и базовыми точками.
type
TRectVisualObject = class(TBaseVisualObject)
protected
function GetVertexesCount: Integer; override;
function GetVertex(Index: Integer): TFloatPoint; override;
procedure SetVertex(Index: Integer; const Value: TFloatPoint); override;
public
constructor Create;
end;
...
implementation
...
constructor TRectVisualObject.Create;
begin
inherited Create;
AddBasePoint(0, 0);
AddBasePoint(0, 0);
end;
function TRectVisualObject.GetVertex(Index: Integer): TFloatPoint;
begin
case Index of
0: Result := BasePoints[0];
1:
begin
Result.X := BasePoints[1].X;
Result.Y := BasePoints[0].Y;
end;
2: Result := BasePoints[1];
3:
begin
Result.X := BasePoints[0].X;
Result.Y := BasePoints[1].Y;
end;
else
TList.Error(@SListIndexError, Index);
end;
end;
function TRectVisualObject.GetVertexesCount: Integer;
begin
Result := 4;
end;
procedure TRectVisualObject.SetVertex(Index: Integer;
const Value: TFloatPoint);
var
Point: TFloatPoint;
begin
case Index of
0:
begin
Point := BasePoints[0];
Point.X := Min(Value.X, BasePoints[1].X);
Point.Y := Min(Value.Y, BasePoints[1].Y);
BasePoints[0] := Point;
end;
1:
begin
Point := BasePoints[1];
Point.X := Max(Value.X, BasePoints[0].X);
BasePoints[1] := Point;
Point := BasePoints[0];
Point.Y := Min(Value.Y, BasePoints[1].Y);
BasePoints[0] := Point;
end;
2:
begin
Point := BasePoints[1];
Point.X := Max(Value.X, BasePoints[0].X);
Point.Y := Max(Value.Y, BasePoints[0].Y);
BasePoints[1] := Point;
end;
3:
begin
Point := BasePoints[0];
Point.X := Min(Value.X, BasePoints[1].X);
BasePoints[0] := Point;
Point := BasePoints[1];
Point.Y := Max(Value.Y, BasePoints[0].Y);
BasePoints[1] := Point;
end;
else
TList.Error(@SListIndexError, Index);
end;
end;
В конструкторе сразу же добавляются две базовые точки в координатах (0; 0). Можно было бы позиционировать объект изначально по-другому, но не суть. Главное то, что больше количество базовых точек нигде меняться не будет. А вот их положение можно изменить, перемещая вершины или стороны объекта. Когда мы меняем положение вершины, вызывается метод SetVertex, который вызывает соответствующее изменение позиций базовых точек. Если при изменении положения 0-й или 2-й вершины нам нужно просто переместить соответственно 0-ю и 1-ю базовую точку, то с 1-й и 3-й вершиной ситуация несколько сложнее. В последнем случае нам нужно поменять по одной координате у двух базовых точек. Метод GetVertex, соответственно, ничего не меняет, а просто возвращает позицию вершины с заданным индексом, высчитывая ее из позиции базовых точек.
Определим действия по растягиванию объекта за его сторону:
type
TRectVisualObject = class(TBaseVisualObject)
private
procedure VOCSideMove(var Command: TVOCSideMove); message VOC_SIDEMOVE;
...
end;
...
implementation
...
...
procedure TRectVisualObject.VOCSideMove(var Command: TVOCSideMove);
var
Point: TFloatPoint;
begin
case Command.Index of
0:
begin
Point := Vertex[0];
Point.X := Command.NewPos^.X;
Vertex[0] := Point;
end;
1:
begin
Point := Vertex[0];
Point.Y := Command.NewPos^.Y;
Vertex[0] := Point;
end;
2:
begin
Point := Vertex[2];
Point.X := Command.NewPos^.X;
Vertex[2] := Point;
end;
3:
begin
Point := Vertex[2];
Point.Y := Command.NewPos^.Y;
Vertex[2] := Point;
end;
else
TList.Error(@SListIndexError, Command.Index);
end;
end;
Перемещение стороны (происходит при получении команды VOC_SIDEMOVE) реализовано таким образом, что мы просто в зависимости от индекса стороны перемещаем по одной оси одну из вершин. Например, при перемещении левой стороны мы просто меняем значение по оси X левой верхней вершины. С таким же успехом можно было бы перемещать по оси X и левую нижнюю вершину.
Теперь научим объекты отзываться на запрос области под курсором и формы курсора в области:
type
TRectVisualObject = class(TBaseVisualObject)
private
...
procedure VOCHitTest(var Command: TVOCHitTest); message VOC_HITTEST;
procedure VOCGetCursor(var Command: TVOCGetCursor); message VOC_GETCURSOR;
...
end;
...
implementation
...
...
procedure TRectVisualObject.VOCGetCursor(var Command: TVOCGetCursor);
begin
case Command.HitTest of
HT_IN: Command.Result := CR_SIZEALL;
HT_VERTEX + 0, HT_VERTEX + 2: Command.Result := CR_DIAG1;
HT_VERTEX + 1, HT_VERTEX + 3: Command.Result := CR_DIAG2;
HT_SIDE + 0, HT_SIDE + 2: Command.Result := CR_HORIZONTAL;
HT_SIDE + 1, HT_SIDE + 3: Command.Result := CR_VERTICAL;
else
Command.Result := CR_DEFAULT;
end;
end;
procedure TRectVisualObject.VOCHitTest(var Command: TVOCHitTest);
var
sX1, sY1, sX2, sY2: Integer;
begin
Command.ConvertIntf.LogToScreen(BasePoints[0].X, BasePoints[0].Y, sX1, sY1);
Command.ConvertIntf.LogToScreen(BasePoints[1].X, BasePoints[1].Y, sX2, sY2);
Command.Result := HT_OUT;
if (Abs(Command.Params.XPos - sX1) <= Command.Params.Tolerance) and
(Abs(Command.Params.YPos - sY1) <= Command.Params.Tolerance)
then begin
Command.Result := HT_VERTEX + 0;
Exit;
end;
if (Abs(Command.Params.XPos - sX2) <= Command.Params.Tolerance) and
(Abs(Command.Params.YPos - sY1) <= Command.Params.Tolerance)
then begin
Command.Result := HT_VERTEX + 1;
Exit;
end;
if (Abs(Command.Params.XPos - sX2) <= Command.Params.Tolerance) and
(Abs(Command.Params.YPos - sY2) <= Command.Params.Tolerance)
then begin
Command.Result := HT_VERTEX + 2;
Exit;
end;
if (Abs(Command.Params.XPos - sX1) <= Command.Params.Tolerance) and
(Abs(Command.Params.YPos - sY2) <= Command.Params.Tolerance)
then begin
Command.Result := HT_VERTEX + 3;
Exit;
end;
if (Abs(Command.Params.XPos - sX1) <= Command.Params.Tolerance) and
(Command.Params.YPos > sY1) and (Command.Params.YPos < sY2)
then begin
Command.Result := HT_SIDE + 0;
Exit;
end;
if (Abs(Command.Params.YPos - sY1) <= Command.Params.Tolerance) and
(Command.Params.XPos > sX1) and (Command.Params.XPos < sX2)
then begin
Command.Result := HT_SIDE + 1;
Exit;
end;
if (Abs(Command.Params.XPos - sX2) <= Command.Params.Tolerance) and
(Command.Params.YPos > sY1) and (Command.Params.YPos < sY2)
then begin
Command.Result := HT_SIDE + 2;
Exit;
end;
if (Abs(Command.Params.YPos - sY2) <= Command.Params.Tolerance) and
(Command.Params.XPos > sX1) and (Command.Params.XPos < sX2)
then begin
Command.Result := HT_SIDE + 3;
Exit;
end;
if (Command.Params.XPos > sX1) and (Command.Params.XPos < sX2) and
(Command.Params.YPos > sY1) and (Command.Params.YPos < sY2)
then begin
Command.Result := HT_IN;
Exit;
end;
end;
Метод обработки команды VOC_HITTEST выглядит громоздким, однако и в нем все достаточно тривиально. Его можно было бы переписать более компактно, но в таком виде он понятнее. Сначала все базовые точки переводятся в экранные координаты, а потом последовательно проверяем, лежит ли точка с координатами (Command.Params.XPos; Command.Params.YPos) на одной из вершин/сторон объекта (с учетом допускаемой погрешности Command.Params.Tolerance) или внутри его. Например, если мы при реализации контейнера зададим Tolerance равным 2, то пользователь сможет "промахнуться" мимо вершины или стороны объекта на 2 пикселя, и при этом будет считаться, что мышь над соответствующей частью объекта.
Метод обработки команды VOC_GETCURSOR принимает в качестве параметра область объекта и возвращает рекомендуемый вид курсора. Например, для левой и правой стороны он вернет код курсора, который можно интерпретировать как "горизонтальная двунаправленная стрелка".
Теперь разберемся с конструированием таких объектов. Оно будет сводиться к двум последовательным щелчкам левой кнопкой мыши, между которым, в процессе движения мыши, будет отображаться предварительный вид объекта. Если после первого щелчка левой кнопкой мыши, пользователь нажмет правую - конструирование объекта будет отменено. После второго щелчка левой кнопкой - конструирование объекта будет закончено. Реализуем все вышесказанное с помощью команд, введенных нами ранее.
type
TRectVisualObject = class(TBaseVisualObject)
private
...
FConstructing: Boolean;
FCurrentPoint: Integer;
procedure VOCConstructPoint(var Command: TVOCConstructPoint);
message VOC_CONSTRUCTPOINT;
procedure VOCProcessConstruct(var Command: TVOCProcessConstruct);
message VOC_PROCESSCONSTRUCT;
procedure VOCStopConstruct(var Command: TVOCStopConstruct);
message VOC_STOPCONSTRUCT;
end;
...
implementation
...
procedure TRectVisualObject.VOCConstructPoint(
var Command: TVOCConstructPoint);
begin
if not FConstructing then begin
FConstructing := True;
FCurrentPoint := 0;
end;
case FCurrentPoint of
0:
begin
BasePoints[0] := Command.Pos^;
BasePoints[1] := Command.Pos^;
Command.Result := 1;
end;
1:
begin
BasePoints[1] := Command.Pos^;
FConstructing := False;
Command.Result := 0;
end;
else
TList.Error(@SListIndexError, FCurrentPoint);
end;
Inc(FCurrentPoint);
end;
procedure TRectVisualObject.VOCProcessConstruct(
var Command: TVOCProcessConstruct);
begin
if FConstructing then
case FCurrentPoint of
0: Vertex[0] := Command.Pos^;
1: Vertex[2] := Command.Pos^;
end;
end;
procedure TRectVisualObject.VOCStopConstruct(
var Command: TVOCStopConstruct);
begin
Command.Result := 1;
if FConstructing then begin
FConstructing := False;
Command.Result := 0;
end;
end;
При получении команды VOC_CONSTRUCTPOINT первый раз - будем переводить объект в режим конструирования, и перемещать все его точки в стартовую. При получении второй раз - устанавливаем позицию второй базовой точки и выходим из режима конструирования. В параметре Result возвращаем 0, сигнализирующий о том, что конструирование закончено. В обработчике команды VOC_PROCESSCONSTRUCT просто обновляем позицию вершины, соответствующей текущей редактируемой точки. Именно вершину изменяем, а не базовую точку, так как при изменении вершины контролируется, чтобы мы вторую точку не задвинули левее или выше первой. В обработчике VOC_STOPCONSTRUCT - выходим из режима конструирования и сигнализируем о том, что конструирование отменено, и объект следует уничтожить. Отправляющий команду код уведомляется об этом через параметр Result, в котором записываем 0.
Ну и последнее - добавим свойство, задающее текст элемента. Реализация в комментариях не нуждается.
type
TRectVisualObject = class(TBaseVisualObject)
private
...
FText: String;
procedure SetText(const Value: String);
...
public
property Text: String read FText write SetText;
end;
...
implementation
...
procedure TRectVisualObject.SetText(const Value: String);
begin
if FText <> Value then begin
FText := Value;
Change;
end;
end;
С прямоугольными объектами закончили, можно смело переходить к объектам-линиям.
Также постараемся включить общее для объектов поведение в базовый класс, а именно:
- Соответствие между базовыми точками и вершинами. В данном случае эти понятия полностью эквивалентны.
- Обработка команды VOC_HITTEST. Для всех объектов данного вида будем возвращать HT_VERTEX + Index, если область соответствует Index-овой вершине, или HT_IN + Index, если область соответствует линии между Index-овой и Index + 1-ой вершиной. А почему такой хитрый код, а не HT_SIDE + Index или просто HT_IN? Дело в том, что мы условились при захвате за линию перемещать объект полностью, а чтобы не переписывать обработчик VOC_DRAG, код области должен содержать HT_IN. А индекс стороны нам пригодиться, когда мы будем добавлять вершины двойным щелчком по стороне объекта "ломаная линия".
- Обработка команды VOC_GETCURSOR.
- Конструирование объекта.
- Прорисовка объекта - будем рисовать линию, последовательно соединяющую все вершины.
Команда VOC_SIDEMOVE такими объектами будет игнорироваться.
Зададим соответствие между вершинами и базовыми точками.
type
...
TLineVisualObject = class(TBaseVisualObject)
protected
function GetVertexesCount: Integer; override;
function GetVertex(Index: Integer): TFloatPoint; override;
procedure SetVertex(Index: Integer; const Value: TFloatPoint); override;
end;
...
implementation
...
function TLineVisualObject.GetVertex(Index: Integer): TFloatPoint;
begin
Result := BasePoints[Index];
end;
function TLineVisualObject.GetVertexesCount: Integer;
begin
Result := BasePointsCount;
end;
procedure TLineVisualObject.SetVertex(Index: Integer;
const Value: TFloatPoint);
begin
BasePoints[Index] := Value;
end;
Тут все очевидно. Можно сразу переходить к обработке команд VOC_HITTEST и VOC_GETCURSOR.
type
...
TLineVisualObject = class(TBaseVisualObject)
private
procedure VOCHitTest(var Command: TVOCHitTest); message VOC_HITTEST;
procedure VOCGetCursor(var Command: TVOCGetCursor); message VOC_GETCURSOR;
end;
...
implementation
...
procedure TLineVisualObject.VOCGetCursor(var Command: TVOCGetCursor);
begin
if Command.HitTest <> HT_OUT then
Command.Result := CR_SIZEALL
else
Command.Result := CR_DEFAULT;
end;
procedure TLineVisualObject.VOCHitTest(var Command: TVOCHitTest);
var
i, sX1, sY1, sX2, sY2: Integer;
D: Extended;
begin
Command.Result := HT_OUT;
for i := VertexesCount - 1 downto 0 do begin
Command.ConvertIntf.LogToScreen(Vertex[i].X, Vertex[i].Y, sX1, sY1);
if (Abs(Command.Params.XPos - sX1) <= Command.Params.Tolerance) and
(Abs(Command.Params.YPos - sY1) <= Command.Params.Tolerance)
then begin
Command.Result := HT_VERTEX + i;
Exit;
end;
end;
for i := VertexesCount - 1 downto 1 do begin
Command.ConvertIntf.LogToScreen(Vertex[i].X, Vertex[i].Y, sX1, sY1);
Command.ConvertIntf.LogToScreen(Vertex[i - 1].X, Vertex[i - 1].Y, sX2, sY2);
D := LineDistance(Command.Params.XPos, Command.Params.YPos,
sX1, sY1, sX2, sY2);
if D <= Command.Params.Tolerance then begin
Command.Result := HT_IN + i - 1;
Exit;
end;
end;
end;
В обработчике VOC_HITTEST мы в цикле проходим по всем вершинам и определяем таким же образом, как в прямоугольных объектах, принадлежность точки вершине. Если вершина найдена - выходим из метода, иначе в следующем цикле проверяем расстояние от точки до отрезка, соединяющего (i - 1)-ю и i-ю вершину. Если оно не превышает допустимую погрешность, то считаем, что точка лежит на линии. Реализацию функции LineDistance, которая рассчитывает расстояние от точки до отрезка, вы найдете в прилагаемых к статье файлах, приводить ее здесь смысла нет. Кстати, используя подобный цикл по вершинам и функцию LineDistance, мы могли бы сократить код обработки команды VOC_HITTEST у прямоугольных объектов.
Теперь реализуем конструирование объектов. Конструирование будет также сводиться к последовательным щелчкам левой кнопкой для фиксации положения вершин, а правая кнопка - завершит конструирование. При этом у объекта останутся только те вершины, что мы успели зафиксировать. Редактируемая вершина будет удаляться.
При получении команды VOC_CONSTRUCTPOINT - будем фиксировать положение текущей редактируемой вершины и добавлять следующую. В этом же методе будем определять, завершено ли конструирование или нет. Для соединительной и ломаной линии здесь поведение будет отличным. При получении команды VOC_PROCESSCONSTRUCT - будем перемещать текущую точку в новую позицию. При получении команды VOC_STOPCONSTRUCT - будем завершать конструирование, удалять текущую редактируемую точку и при необходимости - сигнализировать о том, что объект недостроен.
type
...
TLineVisualObject = class(TBaseVisualObject)
...
FConstructing: Boolean;
FCurrentPoint: Integer;
procedure VOCConstructPoint(var Command: TVOCConstructPoint);
message VOC_CONSTRUCTPOINT;
procedure VOCProcessConstruct(var Command: TVOCProcessConstruct);
message VOC_PROCESSCONSTRUCT;
procedure VOCStopConstruct(var Command: TVOCStopConstruct);
message VOC_STOPCONSTRUCT;
protected
...
function NeedToStopConstruct(Count: Integer): Longint; virtual; abstract;
end;
...
implementation
...
procedure TLineVisualObject.VOCConstructPoint(
var Command: TVOCConstructPoint);
begin
if not FConstructing then begin
FConstructing := True;
BeginUpdate;
try
ClearBasePoints;
FCurrentPoint := 0;
AddBasePoint(Command.Pos^.X, Command.Pos^.Y);
finally
EndUpdate;
end;
end;
Command.Result := NeedToStopConstruct(FCurrentPoint + 1);
if Command.Result = 0 then begin;
FConstructing := False;
Exit;
end;
AddBasePoint(Command.Pos^.X, Command.Pos^.Y);
Inc(FCurrentPoint);
end;
procedure TLineVisualObject.VOCProcessConstruct(
var Command: TVOCProcessConstruct);
begin
if FConstructing then
BasePoints[FCurrentPoint] := Command.Pos^;
end;
procedure TLineVisualObject.VOCStopConstruct(
var Command: TVOCStopConstruct);
begin
Command.Result := 1;
if FConstructing then begin
FConstructing := False;
DeleteBasePoint(FCurrentPoint);
if VertexesCount < 2 then begin
Free;
Command.Result := 0;
end;
end;
end;
Следует пояснить назначение метода NeedToStopConstruct. Этот метод мы перекроем в потомках, чтобы задать соединительной и ломаной линии возможность самостоятельно определить, закончено ли конструирование. При этом если для ломаной линии этот метод будет всегда возвращать код "не закончено" (единственный способ завершить конструирование ломаной - отправить команду VOC_STOPCONSTRUCT), то соединительная линия вернет код "закончено" при установке второй вершины. В этом случае, третья вершина к объекту добавлена не будет, объект просто выйдет из режима конструирования.
Ну и рисование объекта - просто последовательно рисуем все вершины и соединяем их линиями:
type
...
TLineVisualObject = class(TBaseVisualObject)
public
procedure Draw(Canvas: TLogicalCanvas); override;
end;
...
implementation
...
procedure TLineVisualObject.Draw(Canvas: TLogicalCanvas);
var
i: Integer;
begin
inherited;
for i := 1 to VertexesCount - 1 do
Canvas.DrawLine(Vertex[i - 1].X, Vertex[i - 1].Y, Vertex[i].X, Vertex[i].Y, 0.5);
end;
Теперь, когда у нас есть все базовые классы, можно приступить к реализации конкретных объектов. Для примера покажем реализацию объекта "Блок начало/конец" и "ломаной линии". Код остальных объектов принципиально ничем не будет отличаться, приводить в статье его не будем, однако в прилагаемых к статье исходниках он присутствует.
"Блок начало/конец" реализовать достаточно просто. Достаточно просто перекрыть метод Draw, все остальное реализовано в классах-предках.
type
...
TBeginEndBlock = class(TRectVisualObject)
public
procedure Draw(Canvas: TLogicalCanvas); override;
end;
...
implementation
...
procedure TBeginEndBlock.Draw(Canvas: TLogicalCanvas);
begin
Canvas.DrawRoundRect(BasePoints[0].X, BasePoints[0].Y, BasePoints[1].X,
BasePoints[1].Y, 0.5);
Canvas.DrawText(BasePoints[0].X, BasePoints[0].Y, BasePoints[1].X,
BasePoints[1].Y, 3, Text);
inherited Draw(Canvas);
end;
Для ломаной линии необходимо определить еще одну операцию - мы говорили, что двойной щелчок по элементу будет добавлять или удалять вершины, в зависимости от того, на какой области объекта произошел этот щелчок. Назовем команду VOC_VCONTROL и определим в классе ее обработчик.
const
...
VOC_VCONTROL = 12;
type
...
TVOCVControl = packed record
CmdID: Cardinal;
HitTest: Cardinal;
Pos: PFloatPoint;
Result: Longint;
end;
...
TPolyLineBlock = class(TLineVisualObject)
private
procedure VOCVControl(var Command: TVOCVControl); message VOC_VCONTROL;
protected
function NeedToStopConstruct(Count: Integer): Longint; override;
end;
...
implementation
...
function TPolyLineBlock.NeedToStopConstruct(Count: Integer): Longint;
begin
Result := 1;
end;
procedure TPolyLineBlock.VOCVControl(var Command: TVOCVControl);
var
HitTest: Cardinal;
Index: Integer;
begin
HitTest := Command.HitTest and $FFFF0000;
Index := Command.HitTest and $0000FFFF;
case HitTest of
HT_IN: InsertBasePoint(Index + 1, Command.Pos^.X, Command.Pos^.Y);
HT_VERTEX: if VertexesCount > 2 then
DeleteBasePoint(Index);
end;
end;
Мы практически решили поставленную задачу - спроектировали систему классов, реализующих визуальные объекты. Роль управления этими объектами возьмет на себя контейнер, причем его код будет полиморфным - не зависимым от того, какого именно типа являются управляемые им объекты. Для контейнера главное, чтобы все эти объекты были потомками класса TBaseVisualObject.
А кто будет инстанцировать объекты - создавать экземпляры конкретных классов и заносить их в список контейнера? Очевидно, сам контейнер при конструировании объектов пользователем. Но как контейнер будет знать, объект какого именно типа мы хотим создать, и как написать универсальный код инстанцирования? Ответом на первый вопрос напрашивается передавать тип конструируемого объекта извне. Для решения этой задачи в Delphi есть специальный тип - метакласс. Объявляется он с помощью зарезервированных слов "class of":
type
...
TVisualObjectClass = class of TBaseVisualObject;
Значением переменных этого типа будут не экземпляры класса, а сами классы, потомки TBaseVisualObject. Используя метакласс, мы можем написать универсальный код инстанцирования объектов. Другими словами, если нам передана переменная типа метакласс (или как еще говорят - классовая ссылка), мы можем создать экземпляр неизвестного на этапе компиляции класса таким образом:
var
ClassRef: TVisualObjectClass;
Obj: TBaseVisualObject;
...
begin
...
Obj := ClassRef.Create;
Единственное требование, которое нужно соблюсти - конструктор базового класса TBaseVisualObject должен быть виртуальным. Вернемся чуть назад и объявим его таковым (а в потомках - напишем override):
type
TBaseVisualObject = class(TObject)
...
public
constructor Create; virtual;
...
TRectVisualObject = class(TBaseVisualObject)
...
public
constructor Create; override;
Теперь контейнер может даже создавать объекты, не задумываясь об их типе. А этот тип мы будем контейнеру передавать извне.
Теперь, когда классы визуальных объектов готовы, и они правильно умеют обрабатывать команды от клиента, остается только написать компонент-контейнер, который будет обрабатывать пользовательский ввод, интерпретировать команды пользователей в команды объектов и отправлять их последним. Также задачей контейнера является предоставление поверхности, на которую объекты будут выводиться.
Все это - достаточно тривиальная задача, к тому же, вопрос создания собственных компонент - немного из другой оперы. Да и в литературе он освещен куда лучше. Так что подробное описание процесса создания такого контейнера в статье приводиться не будет. Но пример его реализации вы найдете в прилагаемых файлах.
На что следует обратить внимание в коде этого компонента - это на методы обработки сообщений от мыши (в них мышиные сообщения транслируются в команды объектов и передаются последним), на метод Paint, рисующий все объекты на поверхности компонента, на методы перевода из одной системы координат в другие. Также обратите внимание на свойство ObjectType, которое задает тип конструируемого объекта, а когда оно равно nil - контейнер находится в режиме управления объектами (перемещение, выделение), а не конструирования.
Ну что ж, теперь, когда наша система готова, и она получилась в достаточной степени гибкой и расширяемой, сделаем некоторые выводы, которые следует учитывать в будущем при решении других задач проектирования.
- При решении сложных задач имеет смысл разбить ее на более простые составные части и каждую из них решать по отдельности. Эти части должны решать строго свою задачу, а взаимодействие с другими - осуществлять через определенный интерфейс. Причем желательно, чтобы этот интерфейс взаимодействия был максимально простым и логичным.
- Классическим примером разбиения задачи на части является отделение внутренней логики работы от представления на экране.
- Реализуя множество типов объектов, важно грамотно составить иерархию классов. Если объекты характеризуются общим поведением, то следует вывести их от одного предка, в который все это общее поведение включить. Задача потомков будет лишь в определении своей специфики.
- При реализации сущности, которая оперирует разнотипными объектами, но осуществляет с ними однообразные действия, имеет смысл реализовать полиморфизм поведения. Оперирующий объектами, в идеале, ничего не должен знать об их конкретном типе, а обращаться к ним только через общий интерфейс, заложенный в предке. Таким образом, мы сможем написать код, как говорится, "раз и навсегда". Расширять такие системы значительно проще.
Литература:
Э. Гамма, Р. Хелм, Р. Джонсон, Дж. Влиссидес - "Приемы объектно-ориентированного проектирования. Паттерны проектирования".
К материалу прилагаются файлы:
[GDI, рисование на канве]
Обсуждение материала [ 11-04-2012 15:06 ] 82 сообщения |