前情回顾
前文我们实现了心跳,今天来实现头像框裁剪的功能,为以后头像上传和资源服务器做准备。
大体上头像上传框的效果如下

添加设置页面
我们需要在聊天对话框左侧添加设置按钮

左侧设置按钮是我们封装的类StateWidget

右侧添加UserInfoPage界面

UserInfoPage界面布局

属性表

头像裁剪逻辑
点击上传按钮
1 | //上传头像 |
内部调用了我们的ImageCropperDialog,弹出对话框后会显示裁剪图片的界面。
接下来我们看看ImageCropperDialog实现
1 |
|
私有对话框
- 继承自
QDialogQDialog(nullptr):以无父窗口方式创建,独立弹出。Qt::WA_DeleteOnClose:关闭时自动delete对象,防止内存泄漏。setModal(true):对话框模式,阻塞主窗口输入。
- 成员变量
ImageCropperLabel* imageLabel:自定义裁剪视图。QPushButton* btnOk,btnCancel:确认/取消按钮。QPixmap& outputImage:引用外部提供的QPixmap,用来保存裁剪结果。
- 布局管理
- 水平布局 (
QHBoxLayout) 放置按钮并居右。 - 垂直布局 (
QVBoxLayout) 先是大图,再是按钮区。
- 水平布局 (
- Lambda 连接信号与槽
- OK 时,将裁剪后的图像复制给外部引用,然后
close()。 - Cancel 时,将
outputImage置空,表示用户放弃裁剪。
- OK 时,将裁剪后的图像复制给外部引用,然后
静态对话框
- 统一接口:只要一行
ImageCropperDialog::getCroppedImage(…),就能弹出裁剪 UI 并获取结果。 - 输入合法性检查:先用
QPixmap::load()加载文件,失败则弹错并返回空图。 - 阻塞执行:
exec()会进入本地事件循环,直到用户点击 OK/Cancel 关闭对话框。 - 返回结果:通过外部引用
outputImage将裁剪结果“带出”函数作用域。

头像裁剪控件
头文件声明
1 | /************************************************************************* |
具体实现
1 |
|
下面逐步讲解代码实现
枚举类型定义
1 | enum class CropperShape { … }; |
- CropperShape:裁剪框的形状(矩形、正方形、椭圆、圆、以及固定尺寸的变种)。
- OutputShape:导出时输出的形状,仅矩形或椭圆两种。
- SizeType:内部用来控制当图片过大/过小时如何缩放至 Label 尺寸。
这些枚举让 API 更语义化、调用更直观。
类声明与成员变量
1 | class ImageCropperLabel : public QLabel { |
- 核心状态:存了原图、临时图、裁剪框位置、缩放比例等。
- 交互状态:鼠标按下/移动、在哪个拖拽点、是否在拖拽中。
- 可配置属性:边框、拖拽手柄、最小尺寸、遮罩效果等,通过 public 方法暴露给外部。
构造函数(Label 初始化)
1 | ImageCropperLabel::ImageCropperLabel(int width, int height, QWidget* parent) |
- 固定尺寸:确保裁剪界面大小一致,不随容器拉伸。
- 居中显示:图片展示时居中。
- 边框样式:白色虚线。
加载并缩放原图
1 | void ImageCropperLabel::setOriginalImage(const QPixmap &pixmap) { |
- 按保持长宽比的方式,把原图缩放到 Label 区域内(letterbox 模式)。
- **
imageRect**:记录图像在 Label 坐标系下的实际绘制区域。 - **
tempImage**:在 Label 上展示的图,用于用户交互。


裁剪形状设置与重置
1 | void ImageCropperLabel::setRectCropper() { cropperShape = RECT; resetCropperPos(); } |
- 统一调用:每次改变 shape 或大小,都调用
resetCropperPos()让裁剪框回到可见区域中央。


获取裁剪结果
1 | QPixmap ImageCropperLabel::getCroppedImage(OutputShape shape) { |

- 核心思路:先把用户框映射回原图,再按需求做矩形或椭圆裁剪。
为什么要除以 scaledRate?
背景:裁剪区域的坐标 (
cropperRect) 和尺寸 (cropperRect.width(),cropperRect.height()) 都是相对于图像在显示中的位置和大小,而不是原始图像的大小。这意味着显示上的裁剪框可能已经被缩放过。因此,scaledRate是一个缩放比例,用来将裁剪区域从显示坐标系统(可能已经缩放)转换回原始图像的坐标系统。代码解释:
cropperRect.left() - imageRect.left()表示裁剪框左边缘与原始图像左边缘的偏移量(即裁剪框相对于图像的起始位置)。scaledRate是图像在显示时的缩放比例(例如,显示的图像比原图小或大,scaledRate可以是 1、0.5、2 等)。- 除以
scaledRate就是将显示的坐标转换为原始图像的坐标。这样得到的是裁剪框在原始图像中的位置和大小。
例如:假设
scaledRate = 0.5(显示图像是原图的 50%),则cropperRect表示的区域实际在原图中要乘以 2 才能得到正确的大小和位置。
为什么椭圆要单独处理?
裁剪区域的形状是矩形的,而图像本身可能要根据需求切割成不同的形状。如果要求裁剪区域是椭圆形状,那么矩形的裁剪区域必须通过遮罩(mask)来实现。
- 遮罩的作用:
- 默认情况下,裁剪区域是矩形的。为了让裁剪后的图像呈现椭圆形状,我们需要用一个遮罩来过滤掉矩形区域之外的部分。
- 通过绘制一个椭圆(在矩形区域内),并设置遮罩(
mask),使得图像在该遮罩的范围内显示,超出范围的部分将变为透明。
- 椭圆处理的步骤:
- 通过
QBitmap mask(size)创建一个与裁剪区域大小相同的二值遮罩(黑白图像)。 - 然后使用
QPainter绘制一个椭圆形状。drawRoundRect方法画的其实是一个圆角矩形,但由于宽度和高度一样,且角的弯曲度非常高(99, 99),所以它的效果看起来是一个椭圆。 - 最后,通过
resultImage.setMask(mask)将这个椭圆形状应用到裁剪后的图像上,从而实现椭圆形的裁剪效果。
- 通过
painter.setBrush(QColor(0, 0, 0)); 在这里的唯一目的是往那个 QBitmap 遮罩(mask) 上「画」一个黑色的圆角矩形,用来告诉 Qt 哪一块区域要保留、哪一块区域要透明——它并不是在往你的 resultImage 上画黑色。
- 在
mask上:- 黑色 → 可见
- 白色 → 透明
如果你不 setBrush(QColor(0, 0, 0)) 去把圆角矩形「涂黑」,那么整张 mask 就只有白色(或只有透明),结果就是 整张图片都被裁成透明了,你看不见任何内容。
所以,setBrush(QColor(0, 0, 0)) 的作用只是:
- 在
mask上,填充一个黑色的圆角矩形; - 当你调用
resultImage.setMask(mask);时,Qt 会把这部分“黑色”区域映射为 保留原图像素,而把剩下的(白色)区域变成透明。

绘制与遮罩效果
1 | void ImageCropperLabel::paintEvent(QPaintEvent *event) { |
绘制原图
QLabel::paintEvent(event)会根据当前设置的 pixmap 或者绘图内容,把“完整的”图像画到控件上。我们不做任何改动,保留原始像素。叠加遮罩或高光边
根据cropperShape(枚举当前选中的裁剪形状),有两类主要操作:- **
drawRectOpacity()/drawEllipseOpacity()**:在裁剪框以外的区域绘制半透明黑色遮罩,突出裁剪区域本身。 - **
drawSquareEdge(...)**:在裁剪框的四条边或者四个角上绘制高对比度的“小方块”或“手柄”,以便用户拖动调整大小。
绘制裁剪框边线
如果isShowRectBorder==true,再用borderPen(一般是明亮的颜色或宽度可见的线条)精确地把cropperRect描边一次,让裁剪范围更清晰。- **
半透明遮罩
1 | void ImageCropperLabel::drawOpacity(const QPainterPath& path) { |
- **
opacity**:这是一个[0.0 … 1.0]之间的浮点值,控制遮罩的“浓度”。越接近 1.0,黑得越不透明;越接近 0.0,则越接近“无色”。 - **
fillPath(path, QBrush(Qt::black))**:把传入的QPainterPath区域,用半透明的黑色一次性“盖”上去。
drawRectOpacity()
1 | void ImageCropperLabel::drawRectOpacity() { |
- **
imageRect**:通常是整个图片在控件上的显示区域。 - **
cropperRect**:用户定义的“裁剪框”矩形。 - **
p1.subtracted(p2)**:把裁剪框内部切掉,结果p就是“图片区域减去裁剪框”的外部部分。 - 遮罩效果:只有外部部分被半透明黑色盖住,裁剪框内——也就是用户关心的区域——保持原样未被遮盖。
椭圆遮罩 —— drawEllipseOpacity()(原理同上)
虽然你没贴出函数体,但它与 drawRectOpacity() 唯一区别就是把 p2.addRect(cropperRect) 换成:
1 | QPainterPath p2; |
这样 p1.subtracted(p2) 就是“整张图片减去椭圆区域”,半透明遮罩会围着椭圆“环绕”绘制。

“方块手柄”高光 —— drawSquareEdge(bool onlyCorners)
1 | void ImageCropperLabel::drawSquareEdge(bool onlyFourCorners) { |

此函数通常会:
- 在
cropperRect的四条边(或四个角)各计算几个固定大小的小矩形位置。 - 用不透明画刷(如白色或蓝色)绘制这些 “拖拽手柄”,让用户知道可以从这些点出发拖动调整大小。
onlyCorners 参数决定是只在四个角显示手柄,还是在四条边中央也显示。
手柄检测
isPosNearDragSquare(pt1, pt2):手柄附近检测
1 | bool ImageCropperLabel::isPosNearDragSquare(const QPoint& pt1, const QPoint& pt2) { |
- **
pt1**:当前鼠标点(或触点)坐标。 - **
pt2**:某个拖拽手柄中心点坐标。 - **
dragSquareEdge**:定义手柄大小(宽或高)的常量。
逻辑:如果鼠标点到手柄中心的水平距离和垂直距离都不超过 dragSquareEdge/2,就认为“在手柄区域内”。乘以 2 只是把“不超过半边”转成”两倍距离不超过边长“的判断。
getPosInCropperRect(pt):整体位置分类
1 | int ImageCropperLabel::getPosInCropperRect(const QPoint &pt) { |
按照顺序,它分别检测:
- 右边中点
RECT_RIGHT
以(cropperRect.right(), cropperRect.center().y())为中心,看鼠标是否落在右侧手柄区域。 - 右下角
RECT_BOTTOM_RIGHT
以cropperRect.bottomRight()为中心,看鼠标是否落在这个角的手柄。 - 下边中点
RECT_BOTTOM
中点为(center.x(), bottom)。 - 左下角
RECT_BOTTOM_LEFT - 左边中点
RECT_LEFT - 左上角
RECT_TOP_LEFT - 上边中点
RECT_TOP - 右上角
RECT_TOP_RIGHT
如果以上八个拖拽手柄区域都没有命中,接着:
- **
RECT_INSIDE**:如果点严格落在cropperRect内部(第二个参数true表示内边缘也算),就返回“内部”标志。 - **
RECT_OUTSIZD**:都不符合,则认为在裁剪框外。
综合效果
- 在 鼠标按下 或 移动 时,调用
getPosInCropperRect(pt),能够快速定位出当前点相对于裁剪框的位置类型。 - 上层逻辑(如鼠标事件处理)根据这个返回值,决定要进行哪种操作:
- 如果是某个角或边的手柄,就进入“调整大小”模式,且拖拽方向锁定;
- 如果是
RECT_INSIDE,则进入“移动整个裁剪框”模式; - 如果是
RECT_OUTSIZD,则不做任何裁剪框相关的拖拽操作。
这样,就实现了一个用户友好的「拖拽四角/边来调整裁剪框大小,或者拖拽内部来移动框」的交互体验。
鼠标按下移动释放
mousePressEvent
1 | void ImageCropperLabel::mousePressEvent(QMouseEvent *e) { |
功能:当鼠标左键按下时调用。
做了什么:
- 用
e->pos()(相对于控件左上角的坐标)初始化currPos、lastPos,为后续移动计算做准备。 - 将
isLButtonPressed置为true,开启拖动或缩放模式。
mouseMoveEvent
这是核心函数,处理移动和缩放。
1 | void ImageCropperLabel::mouseMoveEvent(QMouseEvent *e) { |
关键点总结
首次定位
当鼠标首次进入mouseMoveEvent,用getPosInCropperRect(currPos)判断鼠标在裁剪框的哪个“热区”——外部、框内、四边、四角中的哪一个,并调用changeCursor()切换对应的鼠标指针样式(如移动箭头、水平/垂直/对角调整形状等),以提示用户下一步操作。左右、上下、四角缩放
- 对于矩形/椭圆,宽高可独立调整;
- 对于正方形/圆,则保证
width == height,并根据位移量较大的一边来驱动另一边; - 对于“固定”模式,则完全不允许用户改变大小。
边界与最小尺寸约束
- 缩放时先判断新的宽度/高度是否 ≥
cropperMinimumWidth/Height; - 再判断新坐标是否会跑出
imageRect(图片区域)之外; - 最后才更新
cropperRect并发信号croppedImageChanged()以便上层 UI 或逻辑更新裁剪后的图像。
- 缩放时先判断新的宽度/高度是否 ≥
拖动整个裁剪框
- 鼠标在框内部拖动(
RECT_INSIDE),计算每次的偏移xOffset,yOffset, - 并先“裁剪”偏移量,使整个框保持在图片范围内,
- 最后调用
translate()平移cropperRect。
- 鼠标在框内部拖动(
mouseReleaseEvent(QMouseEvent *)
1 | void ImageCropperLabel::mouseReleaseEvent(QMouseEvent *) { |
- 功能:当鼠标左键松开时调用。
- 做了什么:
- 将
isLButtonPressed置为false,停止后续的拖动/缩放处理。 - 重置
isCursorPosCalculated = false,下次再移动时会重新计算在哪个区域。 - 恢复默认箭头指针。
- 将
保存逻辑
1 | //上传头像 |
- 选择图片文件(支持多种格式)
1 | QString filename = QFileDialog::getOpenFileName( |
- 功能:当用户点击上传头像按钮时,弹出文件选择对话框(
QFileDialog),允许用户选择图片文件。此对话框支持的文件格式包括.png、.jpg、.jpeg、.bmp和.webp。如果用户没有选择文件(即点击了取消),则返回并不执行后续操作。
- 加载图片文件
1 | QPixmap inputImage; |
- 功能:通过
QPixmap类加载用户选定的图片文件。如果加载失败(如文件损坏、格式不支持等),则弹出错误对话框提示用户,并退出当前函数。
- 裁剪图片
1 | QPixmap image = ImageCropperDialog::getCroppedImage(filename, 600, 400, CropperShape::CIRCLE); |
- 功能:调用
ImageCropperDialog::getCroppedImage函数裁剪图片。这个函数会根据传入的文件路径(filename)、目标大小(600x400)和裁剪形状(此处是圆形CropperShape::CIRCLE)返回一个裁剪后的图片QPixmap。如果裁剪过程失败(即返回空QPixmap),则函数直接退出。
- 缩放图片到指定的
QLabel大小
1 | QPixmap scaledPixmap = image.scaled( ui->head_lb->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); |
- 功能:将裁剪后的图片缩放到与界面上显示头像的
QLabel(head_lb)大小相匹配。使用scaled()方法,保持图片的宽高比 (Qt::KeepAspectRatio),并且应用平滑的图像转换(Qt::SmoothTransformation),保证缩放后的图片质量尽可能高。最后,将缩放后的图片设置到QLabel上,并开启setScaledContents(true),使得QLabel自动调整内容大小以适应其尺寸。
- 获取应用程序的存储目录
1 | QString storageDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); |
- 功能:通过
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)获取应用程序的可写数据存储目录。这个目录是操作系统为应用程序提供的一个常规存储路径,通常用于存储配置文件、数据文件等。
- 创建头像存储目录
1 | QDir dir(storageDir); |
- 功能:检查存储目录下是否已经存在一个名为
avatars的子目录。如果不存在,则通过mkpath()创建该子目录。若创建失败,弹出警告对话框提示用户检查权限或磁盘空间。
- 拼接最终的保存路径
1 | QString filePath = dir.filePath("avatars/head.png"); |
- 功能:拼接最终的文件路径,存储头像的文件名为
head.png,并位于avatars目录下。filePath即为头像图片的完整存储路径。
- 保存裁剪后的图片
1 | if (!scaledPixmap.save(filePath, "PNG")) { |
- 功能:使用
QPixmap::save()方法将裁剪并缩放后的图片保存到指定路径filePath。保存格式为PNG。如果保存失败,则弹出警告对话框提示用户;否则,输出日志,显示头像已成功保存的路径。