在图形应用程序开发中,实现流畅的缩放和平移功能是创建专业级绘图工具的基础。本文将深入探讨如何在Qt Widget中实现CAD级别的交互体验,包括视图变换、坐标系统管理以及交互功能实现。
核心概念:视图变换与坐标系统
在图形应用中,我们需要区分两种坐标系统:
- 逻辑坐标:图形的实际坐标,构成场景的数学模型
- 屏幕坐标:在窗口上实际绘制的像素位置
视图变换由两个参数控制:
QPointF panOffset; // 平移偏移量
double currentScale; // 当前缩放比例
坐标转换通过以下函数实现:
QPointF DrawingWidget::screenToLogical(const QPoint& screenPos) const
{
QPoint center = rect().center();
return QPointF(
(screenPos.x() - center.x() - panOffset.x()) / currentScale,
(center.y() - screenPos.y() - panOffset.y()) / currentScale
);
}
QPointF DrawingWidget::logicalToScreen(const QPointF& logicalPos) const
{
QPoint center = rect().center();
return QPointF(
center.x() + logicalPos.x() * currentScale + panOffset.x(),
center.y() - logicalPos.y() * currentScale - panOffset.y()
);
}
解决方案与实现
1. 视图初始化与自动居中
首次显示时自动调整视图以适应场景:
void DrawingWidget::adjustViewToFit()
{
// 计算场景包围盒
QRectF boundingRect;
for (const Circle& circle : circles) {
QRectF circleRect(circle.center.x() - circle.radius,
circle.center.y() - circle.radius,
2 * circle.radius, 2 * circle.radius);
boundingRect = boundingRect.united(circleRect);
}
// 添加边距
double margin = 0.1 * qMax(boundingRect.width(), boundingRect.height());
boundingRect.adjust(-margin, -margin, margin, margin);
// 计算最佳缩放比例
double widthRatio = width() / boundingRect.width();
double heightRatio = height() / boundingRect.height();
currentScale = qMax(qMin(widthRatio, heightRatio), minScale);
// 计算居中偏移
QPointF centerLogical = boundingRect.center();
panOffset = QPointF(
-centerLogical.x() * currentScale,
-centerLogical.y() * currentScale
);
}
2. 鼠标交互实现
平移功能(中键拖动):
void DrawingWidget::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::MiddleButton) {
isPanning = true;
lastMousePos = event->pos();
setCursor(Qt::ClosedHandCursor);
}
}
void DrawingWidget::mouseMoveEvent(QMouseEvent* event)
{
if (isPanning) {
QPoint delta = event->pos() - lastMousePos;
panOffset += delta; // 仅修改视图参数
lastMousePos = event->pos();
update();
}
}
缩放功能(鼠标滚轮):
void DrawingWidget::wheelEvent(QWheelEvent* event)
{
double zoomFactor = 1.1;
double oldScale = currentScale;
if (event->angleDelta().y() > 0) {
currentScale *= zoomFactor;
} else {
currentScale = qMax(currentScale / zoomFactor, minScale);
}
// 保持缩放中心不变
QPointF mousePos = event->pos();
QPointF logicalMousePos = screenToLogical(mousePos.toPoint());
panOffset = (panOffset + mousePos - rect().center()) * (currentScale / oldScale)
- mousePos + rect().center();
update();
}
3. 坐标信息显示(右键功能)
void DrawingWidget::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::RightButton) {
QPointF logicalPos = screenToLogical(event->pos());
showPosition(logicalPos);
}
}
void DrawingWidget::showPosition(const QPointF& logicalPos)
{
QString message = QString::fromUtf8("实际坐标:\nX: %1\nY: %2")
.arg(logicalPos.x(), 0, 'f', 2)
.arg(logicalPos.y(), 0, 'f', 2);
QMessageBox::information(this,
QString::fromUtf8("坐标信息"),
message,
QMessageBox::Ok);
}
完整实现代码
DrawingWidget.h
#ifndef DRAWINGWIDGET_H
#define DRAWINGWIDGET_H
#include <QWidget>
#include <QMouseEvent>
#include <QPainter>
#include <QVector>
#include <QPointF>
#include <QWheelEvent>
#include <QResizeEvent>
#include <QMessageBox>
class DrawingWidget : public QWidget
{
Q_OBJECT
public:
explicit DrawingWidget(QWidget* parent = nullptr);
~DrawingWidget();
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
// 绘图对象
struct Circle {
QPointF center;
double radius;
QColor color;
};
struct Line {
QPointF start;
QPointF end;
QColor color;
};
struct Point {
QPointF position;
QColor color;
double radius = 5.0;
bool onCircle = false; // 是否在圆上
Circle* circle = nullptr; // 关联的圆
bool onLine = false; // 是否在直线上
Line* line = nullptr; // 关联的直线
};
// 视图控制
QPointF panOffset; // 平移偏移量
double currentScale; // 当前缩放比例
double minScale; // 最小缩放比例
bool isPanning; // 是否正在平移
QPoint lastMousePos; // 上次鼠标位置
int draggingPointIndex; // 正在拖动的点索引
bool initialized; // 是否已初始化
// 绘图数据
QVector<Circle> circles;
QVector<Line> lines;
QVector<Point> points;
// 坐标转换函数
QPointF screenToLogical(const QPoint& screenPos) const;
QPointF logicalToScreen(const QPointF& logicalPos) const;
// 点拖动约束
void movePointToCircle(Point& point, const QPointF& newPos);
void movePointToLine(Point& point, const QPointF& newPos);
// 初始化示例场景
void initScene();
// 调整视图以适应窗口大小
void adjustViewToFit();
// 显示坐标信息
void showPosition(const QPointF& logicalPos);
};
#endif // DRAWINGWIDGET_H
DrawingWidget.cpp
#include "DrawingWidget.h"
#include <cmath>
#include <QPainter>
#include <QWheelEvent>
#include <QDebug>
#include <QResizeEvent>
#include <QApplication>
DrawingWidget::DrawingWidget(QWidget* parent)
: QWidget(parent), currentScale(1.0), minScale(0.1), isPanning(false),
draggingPointIndex(-1), panOffset(0, 0), initialized(false)
{
setMouseTracking(true);
setMinimumSize(400, 400);
setWindowTitle(QString::fromUtf8("CAD级绘图画布"));
initScene();
}
DrawingWidget::~DrawingWidget() {}
void DrawingWidget::initScene()
{
// 创建三个不同颜色的圆
circles.append({{0, 0}, 100, Qt::blue});
circles.append({{-150, 150}, 70, Qt::green});
circles.append({{150, -150}, 80, Qt::red});
// 创建三条不同方向的直线
lines.append({{-200, -200}, {200, 200}, Qt::darkBlue});
lines.append({{-200, 0}, {200, 0}, Qt::darkGreen});
lines.append({{0, -200}, {0, 200}, Qt::darkRed});
// 在圆上创建点
for (int i = 0; i < circles.size(); i++) {
Circle& c = circles[i];
points.append({
{c.center.x() + c.radius, c.center.y()},
Qt::red, 5.0, true, &c
});
points.append({
{c.center.x(), c.center.y() + c.radius},
Qt::blue, 5.0, true, &c
});
}
// 在直线上创建点
for (int i = 0; i < lines.size(); i++) {
Line& l = lines[i];
QPointF midPoint = (l.start + l.end) / 2;
points.append({
midPoint, Qt::magenta, 6.0, false, nullptr, true, &l
});
}
initialized = true;
adjustViewToFit();
}
QPointF DrawingWidget::screenToLogical(const QPoint& screenPos) const
{
QPoint center = rect().center();
return QPointF(
(screenPos.x() - center.x() - panOffset.x()) / currentScale,
(center.y() - screenPos.y() - panOffset.y()) / currentScale
);
}
QPointF DrawingWidget::logicalToScreen(const QPointF& logicalPos) const
{
QPoint center = rect().center();
return QPointF(
center.x() + logicalPos.x() * currentScale + panOffset.x(),
center.y() - logicalPos.y() * currentScale - panOffset.y()
);
}
void DrawingWidget::adjustViewToFit()
{
if (!initialized) return;
QRectF boundingRect;
for (const Circle& circle : circles) {
QRectF circleRect(circle.center.x() - circle.radius,
circle.center.y() - circle.radius,
2 * circle.radius, 2 * circle.radius);
boundingRect = boundingRect.united(circleRect);
}
for (const Line& line : lines) {
boundingRect = boundingRect.united(QRectF(line.start, line.end));
}
if (boundingRect.isEmpty()) return;
double margin = 0.1 * qMax(boundingRect.width(), boundingRect.height());
boundingRect.adjust(-margin, -margin, margin, margin);
double widthRatio = width() / boundingRect.width();
double heightRatio = height() / boundingRect.height();
currentScale = qMax(qMin(widthRatio, heightRatio), minScale);
QPointF centerLogical = boundingRect.center();
panOffset = QPointF(
-centerLogical.x() * currentScale,
-centerLogical.y() * currentScale
);
update();
}
void DrawingWidget::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
// 绘制背景和网格
painter.fillRect(rect(), Qt::white);
// 绘制坐标轴
QPoint center = rect().center();
painter.setPen(Qt::black);
painter.drawLine(0, center.y() + panOffset.y(), width(), center.y() + panOffset.y());
painter.drawText(width() - 20, center.y() + panOffset.y() + 15, QString::fromUtf8("X"));
painter.drawLine(center.x() + panOffset.x(), 0, center.x() + panOffset.x(), height());
painter.drawText(center.x() + panOffset.x() + 10, 15, QString::fromUtf8("Y"));
// 绘制网格
painter.setPen(QPen(Qt::lightGray, 0.5));
int gridSize = 20;
for (int x = static_cast<int>(panOffset.x()) % gridSize; x < width(); x += gridSize) {
painter.drawLine(x, 0, x, height());
}
for (int y = static_cast<int>(panOffset.y()) % gridSize; y < height(); y += gridSize) {
painter.drawLine(0, y, width(), y);
}
// 绘制直线
for (const Line& line : lines) {
QPointF start = logicalToScreen(line.start);
QPointF end = logicalToScreen(line.end);
painter.setPen(QPen(line.color, 2));
painter.drawLine(start, end);
}
// 绘制圆
for (const Circle& circle : circles) {
QPointF centerScreen = logicalToScreen(circle.center);
double radiusScreen = circle.radius * currentScale;
painter.setPen(QPen(circle.color, 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(centerScreen, radiusScreen, radiusScreen);
}
// 绘制点
for (const Point& point : points) {
QPointF posScreen = logicalToScreen(point.position);
double radiusScreen = point.radius * currentScale;
painter.setPen(Qt::black);
painter.setBrush(point.color);
painter.drawEllipse(posScreen, radiusScreen, radiusScreen);
}
// 显示缩放比例
painter.setPen(Qt::black);
painter.drawText(10, 20, QString::fromUtf8("缩放: %1x").arg(currentScale, 0, 'f', 1));
}
void DrawingWidget::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::MiddleButton) {
isPanning = true;
lastMousePos = event->pos();
setCursor(Qt::ClosedHandCursor);
}
else if (event->button() == Qt::LeftButton) {
QPoint screenPos = event->pos();
for (int i = 0; i < points.size(); i++) {
const Point& point = points[i];
QPointF pointScreen = logicalToScreen(point.position);
double dx = pointScreen.x() - screenPos.x();
double dy = pointScreen.y() - screenPos.y();
double distance = std::sqrt(dx*dx + dy*dy);
if (distance < 10.0 * currentScale) {
draggingPointIndex = i;
return;
}
}
}
else if (event->button() == Qt::RightButton) {
QPointF logicalPos = screenToLogical(event->pos());
showPosition(logicalPos);
}
}
void DrawingWidget::mouseMoveEvent(QMouseEvent* event)
{
if (isPanning) {
QPoint delta = event->pos() - lastMousePos;
panOffset += delta;
lastMousePos = event->pos();
update();
}
else if (draggingPointIndex >= 0) {
Point& point = points[draggingPointIndex];
QPointF newLogicalPos = screenToLogical(event->pos());
if (point.onCircle && point.circle) {
movePointToCircle(point, newLogicalPos);
} else if (point.onLine && point.line) {
movePointToLine(point, newLogicalPos);
} else {
point.position = newLogicalPos;
}
update();
}
}
void DrawingWidget::mouseReleaseEvent(QMouseEvent* event)
{
if (event->button() == Qt::MiddleButton) {
isPanning = false;
setCursor(Qt::ArrowCursor);
}
else if (event->button() == Qt::LeftButton) {
draggingPointIndex = -1;
}
}
void DrawingWidget::wheelEvent(QWheelEvent* event)
{
double zoomFactor = 1.1;
double oldScale = currentScale;
if (event->angleDelta().y() > 0) {
currentScale *= zoomFactor;
} else {
currentScale = qMax(currentScale / zoomFactor, minScale);
}
QPointF mousePos = event->pos();
panOffset = (panOffset + mousePos - rect().center()) * (currentScale / oldScale)
- mousePos + rect().center();
update();
event->accept();
}
void DrawingWidget::resizeEvent(QResizeEvent* event)
{
Q_UNUSED(event);
adjustViewToFit();
}
void DrawingWidget::movePointToCircle(Point& point, const QPointF& newPos)
{
if (!point.circle) return;
Circle& circle = *point.circle;
QPointF dir = newPos - circle.center;
double distance = std::sqrt(dir.x()*dir.x() + dir.y()*dir.y());
if (distance > 0) {
point.position = circle.center + dir * (circle.radius / distance);
}
}
void DrawingWidget::movePointToLine(Point& point, const QPointF& newPos)
{
if (!point.line) return;
Line& line = *point.line;
QPointF lineVec = line.end - line.start;
double lineLengthSquared = lineVec.x()*lineVec.x() + lineVec.y()*lineVec.y();
if (lineLengthSquared > 0) {
QPointF relVec = newPos - line.start;
double t = (relVec.x()*lineVec.x() + relVec.y()*lineVec.y()) / lineLengthSquared;
t = qBound(0.0, t, 1.0);
point.position = line.start + lineVec * t;
}
}
void DrawingWidget::showPosition(const QPointF& logicalPos)
{
QString message = QString::fromUtf8("实际坐标:\nX: %1\nY: %2")
.arg(logicalPos.x(), 0, 'f', 2)
.arg(logicalPos.y(), 0, 'f', 2);
QMessageBox::information(this, QString::fromUtf8("坐标信息"), message);
}
关键技术与最佳实践
坐标系统分离:
- 严格区分逻辑坐标(场景坐标)和屏幕坐标(显示坐标)
- 所有图形对象使用逻辑坐标存储
- 仅在绘制时转换为屏幕坐标
高效视图变换:
- 使用
panOffset
和currentScale
控制视图 - 避免修改原始图形数据
- 矩阵运算保持高性能
- 使用
智能视图初始化:
- 自动计算场景包围盒
- 添加合理边距
- 自适应窗口尺寸
交互体验优化:
- 中键平移自然流畅
- 滚轮缩放以光标为中心
- 右键坐标显示实用直观
约束点拖动:
- 圆上点沿圆周移动
- 线上点沿线段移动
- 保持几何关系不变
总结
本文详细介绍了在Qt Widget中实现CAD级绘图画布的核心技术,包括视图变换、坐标系统管理、交互功能实现等关键内容。通过分离逻辑坐标和屏幕坐标,我们实现了:
- 流畅的缩放和平移体验
- 稳定的坐标系统(图形实际坐标不随视图改变)
- 实用的右键坐标显示功能
- 智能的视图初始化与自适应
- 约束点拖动功能
这些技术不仅适用于CAD类应用,也可用于科学可视化、数据分析和任何需要复杂交互的图形应用程序。通过本文提供的完整实现,开发者可以快速构建出专业级的图形交互界面。