The OpenNET Project / Index page

[ новости /+++ | форум | wiki | теги | ]

Каталог документации / Раздел "Программирование, языки" / Оглавление документа

8.2. Рисование средствами QCanvas.

QCanvas (Canvas -- холст, полотно, канва. прим. перев.) предоставляет более высокоуровневый интерфейс, чем QPainter. Он может включать в себя элементы любой формы и имеет внутреннюю реализацию двойной буферизации. Для приложений, которые занимаются визуализацией информации или двухмерных игр, выбор QCanvas может оказаться лучшим решением.

Элементы, которые может отображать QCanvas, являются экземплярами класса QCanvasItem или его потомков. Qt содержит неплохой набор предопределенных графических элементов: QCanvasLine, QCanvasRectangle, QCanvasPolygon, QCanvasPolygonalItem, QCanvasEllipse, QCanvasSpline, QCanvasSprite и QCanvasText..

Классы QCanvas и QCanvasItem -- просто данные, они не имеют визуального представления. Для отображения QCanvas и его элементов мы должны использовать виджет QCanvasView. Такое разделение данных и средств их отображения, позволяет отображать один и тот же QCanvas в нескольких QCanvasView, причем каждый из них может визуализировать свою собственную часть QCanvas, причем с применением различных матриц преобразования.

Класс QCanvas оптимизирован для работы с большим количеством элементов. Когда изменяется какой либо элемент, то перерисовывается только та часть, которая действительно изменилась. В нем так же заложен эффективный алгоритм проверки на пересечение. Поэтому, QCanvas можно смело рассматривать как неплохую альтернативу подходам, связанным с перекрытием родительских методов paintEvent() и QScrollView::drawContents().

Рисунок 8.9. Внешний вид виджета DiagramView.


С целью демонстрации основных приемов работы с QCanvas, приведем исходный код виджета DiagramView -- редактора структурных диаграмм. Виджет поддерживает два типа фигур (прямоугольники и линии) и имеет контекстное меню, которое дает возможность вставить новый элемент в диаграмму, копировать элементы в буфер обмена, вставлять их из буфера обмена, удалять и изменять их свойства.
class DiagramView : public QCanvasView 
{ 
  Q_OBJECT 
  
public: 
  DiagramView(QCanvas *canvas, QWidget *parent = 0, const char *name = 0); 
  
public slots: 
  void cut(); 
  void copy(); 
  void paste(); 
  void del(); 
  void properties(); 
  void addBox();
  void addLine(); 
  void bringToFront(); 
  void sendToBack();
      
Класс DiagramView порожден от класса QCanvasView, который в свою очередь ведет родословную от класса QScrollView. Он предоставляет массу публичных слотов, через которые возможно взаимодействие с приложением. Эти слоты так же используются и самим виджетом, для обслуживания контекстного меню.
protected: 
  void contentsContextMenuEvent(QContextMenuEvent *event); 
  void contentsMousePressEvent(QMouseEvent *event); 
  void contentsMouseMoveEvent(QMouseEvent *event); 
  void contentsMouseDoubleClickEvent(QMouseEvent *event); 
  
private: 
  void createActions(); 
  void addItem(QCanvasItem *item); 
  void setActiveItem(QCanvasItem *item); 
  void showNewItem(QCanvasItem *item); 
  
  QCanvasItem *pendingItem; 
  QCanvasItem *activeItem; 
  QPoint lastPos; 
  int minZ; 
  int maxZ; 
  
  QAction *cutAct; 
  QAction *copyAct; 
  ... 
  QAction *sendToBackAct; 
};
      
Приватные и защищенные члены класса мы будем описывать очень коротко.

Рисунок 8.10. Элементы DiagramBox и DiagramLine.


Помимо класса DiagramView, нам необходимо определить два класса элеменов даграмм. Назовем эти классы как DiagramBox и DiagramLine.
class DiagramBox : public QCanvasRectangle 
{ 
public: 
  enum { RTTI = 1001 }; 
  
  DiagramBox(QCanvas *canvas); 
  ~DiagramBox(); 
  
  void setText(const QString &newText);
  QString text() const { return str; } 
  void drawShape(QPainter &painter);
  QRect boundingRect() const; 
  int rtti() const { return RTTI; } 

private: 
  QString str; 
};
      
Элемент диаграммы DiagramBox отображается в виде прямоугольника, с текстом внутри. Он наследует значительную часть функциональности от своего предка -- класса QCanvasRectangle, в который добавлена возможность рисования дополнительного текста и маленьких квадратиков по углам, для индикации активности элемента. В реальном приложении, эти квадратики можно было бы использовать для того, чтобы изменять размеры прямоугольника, но в данном случае, для упрощения примера, мы не будем этого делать.

Функция rtti() перекрывает родительский метод. Имя этой функции происходит от английского "run-time type identification" -- "идентификация типа во время исполнения". Возвращаемый ею результат будет сравниваться с константой RTTI, чтобы узнать -- является ли тот или иной элемент объектом класса DiagramBox. Эту же проверку можно было бы выполнить с использованием механизма C++ dynamic_cast<T>(), но это ограничило бы нас в выборе компилятора C++.

Число 1001 выбрано случайным образом. Приемлемо любое значение, большее 1000, единственное ограничение: в одном и том же приложении не должны использоваться разные классы с одинаковым значением RTTI.

class DiagramLine : public QCanvasLine 
{ 
public: 
  enum { RTTI = 1002 }; 
  
  DiagramLine(QCanvas *canvas); 
  ~DiagramLine(); 
  
  QPoint offset() const { return QPoint((int)x(), (int)y()); } 
  void drawShape(QPainter &painter); 
  QPointArray areaPoints() const; 
  int rtti() const { return RTTI; } 
};
      
Элемент диаграммы DiagramLine отображается в виде линии. Этот класс наследует функциональность класса QCanvasLine, в который добавлена возможность отображения маленьких квадратиков на концах линии, для индикации активности элемента.

Перейдем к обзору реализации этих трех классов:

DiagramView::DiagramView(QCanvas *canvas, QWidget *parent, 
                         const char *name) 
    : QCanvasView(canvas, parent, name) 
{ 
  pendingItem = 0; 
  activeItem = 0; 
  minZ = 0; 
  maxZ = 0; 
  createActions(); 
}
      
Конструктор DiagramView в первом аргументе получает указатель на QCanvas и передает его унаследованному конструктору.

В приватной функции createActions() создаются экземпляры QAction. Мы уже рассматривали подобные функции в примерах ранее, поэтому реализацию этой функции мы опустим.

void DiagramView::contentsContextMenuEvent(QContextMenuEvent *event) 
{ 
  QPopupMenu contextMenu(this); 
  if (activeItem) { 
    cutAct->addTo(&contextMenu); 
    copyAct->addTo(&contextMenu); 
    deleteAct->addTo(&contextMenu); 
    contextMenu.insertSeparator(); 
    bringToFrontAct->addTo(&contextMenu); 
    sendToBackAct->addTo(&contextMenu); 
    contextMenu.insertSeparator(); 
    propertiesAct->addTo(&contextMenu); 
  } else { 
    pasteAct->addTo(&contextMenu); 
    contextMenu.insertSeparator(); 
    addBoxAct->addTo(&contextMenu); 
    addLineAct->addTo(&contextMenu); 
  } 
  contextMenu.exec(event->globalPos()); 
}
      
Чтобы создать контекстное меню, мы перекрыли обработчик contentsContextMenuEvent() родительского класса QScrollView.

Рисунок 8.11. Контекстное меню виджета DiagramView.


Если к моменту поступления события был активизирован какой либо из элементов диаграммы, то меню будет содержать пункты, которые имеют отношение к выделенному элементу: Cut, Copy, Delete, Bring to Front, Send to Back и Properties. В противном случае меню будет состоять из трех пунктов: Paste, Add Box и Add Line.
void DiagramView::addBox() 
{ 
  addItem(new DiagramBox(canvas())); 
} 

void DiagramView::addLine() 
{ 
  addItem(new DiagramLine(canvas())); 
}
      
Слоты addBox() и addLine() создают элементы диаграммы DiagramBox или DiagramLine, соответственно, которые затем добавляются в виджет, с помощью addItem().
void DiagramView::addItem(QCanvasItem *item) 
{ 
  delete pendingItem; 
  pendingItem = item; 
  setActiveItem(0); 
  setCursor(crossCursor); 
}
      
Приватная функция addItem() изменяет внешний вид указателя мыши на крестик и записывает в переменную pendingItem указатель на вновь созданный элемент. Этот элемент не будет видим на экране до тех пор, пока не будет вызван его метод show().

Когда пользователь выбирает пункт контекстного меню Add Box или Add Line, изменяется внешний вид указателя мыши, но элемент будет добавлен только когда он щелкнет по канве.

void DiagramView::contentsMousePressEvent(QMouseEvent *event) 
{ 
  if (event->button() == LeftButton << pendingItem) { 
    pendingItem->move(event->pos().x(), event->pos().y()); 
    showNewItem(pendingItem); 
    pendingItem = 0; 
    unsetCursor(); 
  } else { 
    QCanvasItemList items = canvas()->collisions(event->pos()); 
    if (items.empty()) 
      setActiveItem(0); 
    else 
      setActiveItem(*items.begin()); 
  } 
  lastPos = event->pos(); 
}
      
Когда пользователь нажимает левую кнопку мыши и при этом курсор отображается в виде крестика, то вставляемый элемент диаграммы уже создан. Поэтому нам остается только вставить его в позицию курсора мыши, сделать видимым и вернуть внешний виж курсовра в первоначальное состояние.

Любой другой щелчок по канве интерпретируется как попытка выделить какой либо из элементов или наоборот, снять выделение. Функция collisions() возвращает список всех элементов, находящихся под указателем мыши. Первый из этого списка активизируется. Если список содержит несколько элементов, то первым в нем всегда будет стоять тот элемент, который отображается поверх других.

void DiagramView::contentsMouseMoveEvent(QMouseEvent *event) 
{ 
  if (event->state() & LeftButton) { 
    if (activeItem) { 
      activeItem->moveBy(event->pos().x() - lastPos.x(), 
                         event->pos().y() - lastPos.y()); 
      lastPos = event->pos();
      canvas()->update(); 
    } 
  } 
}
      
Пользователь может перемещать элементы диаграммы, удерживая их левой кнопкой мыши. Каждый раз, когда виджет получает событие, извещающее о перемещении мыши, мы сдвигаем элемент по горизонтали и вертикали, на полученные расстояния и вызываем update() канвы. Всякий раз, когда изменяется содержимое канвы, мы должны вызывать метод update(), чтобы перерисовать виджет.
void DiagramView::contentsMouseDoubleClickEvent(QMouseEvent *event) 
{ 
  if (event->button() == LeftButton && activeItem 
           && activeItem->rtti() == DiagramBox::RTTI) { 
    DiagramBox *box = (DiagramBox *)activeItem; 
    bool ok; 
    
    QString newText = QInputDialog::getText( 
            tr("Diagram"), tr("Enter new text:"), 
            QLineEdit::Normal, box->text(), &ok, this); 
    if (ok) { 
      box->setText(newText); 
      canvas()->update(); 
    } 
  } 
}
      
Когда пользователь выполняет двойной щелчок по элементу диаграммы, вызывается функция rtti(), а полученное от нее значение сравнивается с DiagramBox::RTTI (1001).

Рисунок 8.12. Диалог изменения текста в элементе DiagramBox.


Если это действительно DiagramBox, то запускается QInputDialog, что позволяет пользователю изменить текст, отображаемый внутри прямоугольника.
void DiagramView::bringToFront() 
{ 
  if (activeItem) { 
    ++maxZ; 
    activeItem->setZ(maxZ); 
    canvas()->update(); 
  } 
}
      
Слот bringToFront() перемещает выбранный элемент поверх других элементов диаграммы. Это достигается за счет записи значания, в координату z компонента, большего, чем у других. Если на канве, в тех же самых координатах, находятся два или более компонентов, то тот, который имеет большее значение координаты z будет отображаться поверх остальных.
void DiagramView::sendToBack() 
{ 
  if (activeItem) { 
    --minZ; 
    activeItem->setZ(minZ); 
    canvas()->update(); 
  } 
}
      
Слот sendToBack() перемещает выбранный элемент ниже других. Это достигается за счет записи значания, в координату z компонента, меньшего, чем у других.
void DiagramView::cut() 
{ 
  copy(); 
  del(); 
}
      
Реализация слота cut() достаточно проста, и мы не будем его подробно описывать.
void DiagramView::copy() 
{ 
  if (activeItem) { 
    QString str; 
    
    if (activeItem->rtti() == DiagramBox::RTTI) { 
      DiagramBox *box = (DiagramBox *)activeItem; 
      str = QString("DiagramBox %1 %2 %3 %4 %5") 
            .arg(box->width()) 
            .arg(box->height()) 
            .arg(box->pen().color().name()) 
            .arg(box->brush().color().name()) 
            .arg(box->text()); 
    } else if (activeItem->rtti() == DiagramLine::RTTI) { 
      DiagramLine *line = (DiagramLine *)activeItem; 
      QPoint delta = line->endPoint() - line->startPoint(); 
      str = QString("DiagramLine %1 %2 %3") 
            .arg(delta.x()) 
            .arg(delta.y()) 
            .arg(line->pen().color().name()); 
    } 
    QApplication::clipboard()->setText(str); 
  } 
}
      
Слот copy() преобразует информацию об элементе в строку и копирует ее в буфер обмена. Строка содержит все необходимые сведения, чтобы потом можно было опять воссоздать элемент. Например, прямоугольник черного цвета, с текстом "My Left Foot" белого цвета, будет представлен в виде строки:
      DiagramBox 320 40 #000000 #ffffff My Left Foot
      
Нет необходимости беспокоиться о сохранении координат элемента. Когда элемент вынимается из буфера обмена, он просто вставляется в левый верхний угол канвы. Представление объекта в виде строки -- это самый простой способ добавить поддержку буфера обмена. Безусловно, буфер обмена может хранить и двоичные данные в произвольном формате, но об этом мы поговорим в Главе 9.
void DiagramView::paste() 
{ 
  QString str = QApplication::clipboard()->text(); 
  QTextIStream in(&str); 
  QString tag; 
  
  in >> tag; 
  if (tag == "DiagramBox") { 
    int width; 
    int height; 
    QString lineColor; 
    QString fillColor; 
    QString text; 
     
    in >> width >> height >> lineColor >> fillColor; 
    text = in.read(); 
     
    DiagramBox *box = new DiagramBox(canvas()); 
    box->move(20, 20); 
    box->setSize(width, height); 
    box->setText(text); 
    box->setPen(QColor(lineColor)); 
    box->setBrush(QColor(fillColor)); 
    showNewItem(box); 
  } else if (tag == "DiagramLine") {
    int deltaX; 
    int deltaY; 
    QString lineColor; 
      
    in >> deltaX >> deltaY >> lineColor; 
      
    DiagramLine *line = new DiagramLine(canvas()); 
    line->move(20, 20); 
    line->setPoints(0, 0, deltaX, deltaY); 
    line->setPen(QColor(lineColor)); 
    showNewItem(line); 
  } 
}
      
Слот paste() пользуется услугами QTextIStream, для разбора содержимого строки из буфера обмена. QTextIStream отделяет поля в строке по символу пробела, точно так же, как и cin. Поля считываются оператором ">>", за исключением последнего, которое может содержать пробелы. Чтобы прочитать последнее поле используется метод QTextStream::read(), который возвращает остаток строки.
void DiagramView::del() 
{ 
  if (activeItem) {
    QCanvasItem *item = activeItem; 
    setActiveItem(0); 
    delete item; 
    canvas()->update(); 
  } 
}
      
Слот del() удаляет активный элемент и перерисовывает канву.
void DiagramView::properties() 
{ 
  if (activeItem) { 
    PropertiesDialog dialog; 
    dialog.exec(activeItem); 
  } 
}
      
Слот properties() запускает диалог изменения свойств активного элемента. Класс PropertiesDialog получает только указатель на элемент, и сам определяет -- какого типа элемент он получил, после чего выполняет все необходимые действия.

Рисунок 8.13. Два варианта отображения диалога PropertiesDialog.


Файлы .ui и .ui.h вы найдете на CD, сопровождающем книгу.
void DiagramView::showNewItem(QCanvasItem *item) 
{ 
  setActiveItem(item); 
  bringToFront(); 
  item->show(); 
  canvas()->update(); 
}
      
Функция showNewItem() активизирует элемент диаграммы и делает его видимым.
void DiagramView::setActiveItem(QCanvasItem *item) 
{
  if (item != activeItem) { 
    if (activeItem) 
      activeItem->setActive(false); 
    activeItem = item; 
    if (activeItem) 
      activeItem->setActive(true); 
    canvas()->update(); 
  } 
}
      
Последняя функция setActiveItem() сбрасывает признак активности у предыдущего активного элемента, запоминает указатель на новый активный элемент и активизирует его. Признак активности элемента хранится в классе QCanvasItem. Qt не использует его, но предоставляет такую возможность для удобства разработчика. Мы используем этот признак, поскольку в нашем случае активные элементы рисуются несколько иначе, чем неактивные.

Перейдем к рассмотрению реализации классов DiagramBox и DiagramLine.

const int Margin = 2; 

void drawActiveHandle(QPainter &painter, const QPoint &center) 
{ 
  painter.setPen(Qt::black); 
  painter.setBrush(Qt::gray); 
  painter.drawRect(center.x() - Margin, center.y() - Margin, 
                   2 * Margin + 1, 2 * Margin + 1); 
}
      
Функция drawActiveHandle() рисует маленькие квадратики, для индикации активности элемента диаграммы.
DiagramBox::DiagramBox(QCanvas *canvas) 
    : QCanvasRectangle(canvas) 
{ 
  setSize(100, 60); 
  setPen(black); 
  setBrush(white); 
  str = "Text"; 
}
      
В конструкторе задаются начальные размеры прямоугольника 100 X 60, цвет пера (черный) и цвет кисти (белый). Цветом пера отображаются границы прямоугольника и текст, цветом кисти заливается внутреннее пространство прямоугольника.
DiagramBox::~DiagramBox() 
{ 
  hide(); 
}
      
Деструктор скрывает элемент диаграммы, вызовом метода hide(). Это необходимо для любых классов, порожденных от QCanvasPolygonalItem (базовый класс для QCanvasRectangle).
void DiagramBox::setText(const QString &newText) 
{ 
  str = newText; 
  update(); 
}
      
Функция setText() записывает текст, который должен отображаться в прямоугольнике, и вызывает QCanvasItem::update(), чтобы отобразить изменения на экране.
void DiagramBox::drawShape(QPainter &painter) 
{ 
  QCanvasRectangle::drawShape(painter); 
  painter.drawText(rect(), AlignCenter, text()); 
  if (isActive()) { 
    drawActiveHandle(painter, rect().topLeft()); 
    drawActiveHandle(painter, rect().topRight()); 
    drawActiveHandle(painter, rect().bottomLeft()); 
    drawActiveHandle(painter, rect().bottomRight()); 
  } 
}
      
Функция drawShape() перекрывает метод класса QCanvasPolygonalItem, чтобы нарисовать текст и маленькие квадратики по углам, если данный элемент диаграммы активен. Сам прямоугольник рисуется родительским методом.
QRect DiagramBox::boundingRect() const 
{ 
  return QRect((int)x() - Margin, (int)y() - Margin, 
               width() + 2 * Margin, height() + 2 * Margin); 
}
      
Функция boundingRect() перекрывает метод класса QCanvasItem. Она вызывается классом QCanvas, для проверки наложения одних элементов на другие и оптимизации перерисовки. Возвращаемые размеры должны быть не меньше тех, которые получает drawShape().

Значение, которое возвращает родительский метод, нас не устраивает потому, что он не учитывает размеры маленьких квадратиков, рисуемых по углам активного элемента.

DiagramLine::DiagramLine(QCanvas *canvas) 
    : QCanvasLine(canvas) 
{ 
  setPoints(0, 0, 0, 99); 
}
      
Конструктор DiagramLine задает координаты точек, между которыми будет нарисована линия: (0, 0) и (0, 99). В результате получается вертикальная линия, длиной в 100 пикселей.
DiagramLine::~DiagramLine() 
{ 
  hide(); 
}
      
Опять же, в деструкторе необходимо скрыть элемент.
void DiagramLine::drawShape(QPainter &painter) 
{ 
  QCanvasLine::drawShape(painter); 
  if (isActive()) { 
    drawActiveHandle(painter, startPoint() + offset()); 
    drawActiveHandle(painter, endPoint() + offset()); 
  } 
}
      
Функция drawShape() перекрывает родительский метод, чтобы нарисовать маленькие квадратики на концах линии, если элемент активен. Сама линия рисуется средствами родительского класса. Реализация функции offset() находится внутри определения класса DiagramLine. Она возвращает положение элемента на канве.
QPointArray DiagramLine::areaPoints() const 
{ 
  const int Extra = Margin + 1; 
  QPointArray points(6); 
  QPoint pointA = startPoint() + offset(); 
  QPoint pointB = endPoint() + offset(); 
  
  if (pointA.x() > pointB.x()) 
    swap(pointA, pointB); 
  
  points[0] = pointA + QPoint(-Extra, -Extra); 
  points[1] = pointA + QPoint(-Extra, +Extra); 
  points[3] = pointB + QPoint(+Extra, +Extra); 
  points[4] = pointB + QPoint(+Extra, -Extra); 
  if (pointA.y() > pointB.y()) { 
    points[2] = pointA + QPoint(+Extra, +Extra); 
    points[5] = pointB + QPoint(-Extra, -Extra); 
  } else { 
    points[2] = pointB + QPoint(-Extra, +Extra); 
    points[5] = pointA + QPoint(+Extra, -Extra); 
  } 
  return points; 
}
      
Функция areaPoints() играет роль, аналогичную boundingRect() класса DiagramBox. Аппроксимация области, принадлежащей диагональной линии, прямоугольником будет слишком грубым приближением. Потому необходимо перекрыть родительский метод и вернуть более точные границы области рисования элемента. В принципе, реализация метода в классе QCanvasLine уже возвращает приемлемые границы, но она не учитывает маленькие квадратики, которые рисуются у активных элементов.

Первое, что делает функция -- сохраняет координаты точек во временных переменных pointA и pointB, а затем проверяет -- находится ли точка pointA левее точки pointB и меняет их местами, если это необходимо, с помощью функции swap() (определена в <algorithm>). После этого она выполняет различные действия для ниспадающих и восстающих линий.

Границы области рисования линии всегда представляются в виде 6 точек, но их координаты существенно зависят от того -- ниспадающая линия или восстающая. Однако, координаты 4-х точек из 6-ти (0, 1, 3 и 4) всегда одинаковы для обоих случаев. Например, точки 0 и 1 всегда определяют левый верхний и левый нижний углы конца A, а точка 2 задает правый нижний угол для восстающих линий на конце A и левый нижний угол для ниспадающих линий на конце B.

Рисунок 8.14. Границы области рисования линий DiagramLine.


При рассмотрении кода, который мы написали, вы наверняка заметили, что виджет DiagramView реализует достаточно большой объем функциональности, отвечающей за выделение элементов и их перемещение по канве, а так же предоставляет контекстное меню для взаимодействия с пользователем.

Одна деталь, которую мы опустили -- пользователь не может изменить размеры элемента, манипулируя маленькими квадратиками. Если бы мы хотели добавить такую возможность, то скорее всего нам пришлось бы сделать все немного иначе. Вместо того, чтобы рисовать квадратики в drawShape(), нам скорее всего пришлось бы сделать их самостоятельными элементами канвы. И изменять внешний вид указателя мыши, вызовом setCursor(), когда он находится над квадратиком, но для этого, сначала потребовалось бы вызвать setMouseTracking(true), потому что обычно Qt передает события перемещения мыши только тогда, когда какая либо кнопка мыши удерживается в нажатом состоянии.

Кроме того, можно было бы расширить набор элементов диаграмм, сделать возможным выделение нескольких элементов диаграммы одновременно и добавить возможность объединения элементов в группы. Статья "Canvas Item Groupies", в ежеквартальнике Qt Quarterly ( http://doc.trolltech.com/qq/qq05-canvasitemgrouping.html), описывает один из приемов реализации подобных возможностей.

В этом разделе мы предоставили пример работающего кода, использующего функциональность классов QCanvas и QCanvasView, но не раскрыли всех возможностей класса QCanvas. Например, элементы могут перемещаться по канве, если им указать скорость перемещения вызовом метода setVelocity(). За подробной информацией обращайтесь к сопроводительной документации.




Спонсоры:
Inferno Solutions
Hosting by Hoster.ru
Хостинг:

Закладки на сайте
Проследить за страницей
Created 1996-2021 by Maxim Chirkov
Добавить, Поддержать, Вебмастеру