在“改进2型”中,我们给图片查看器添加了一个缩放的功能。
这里我们添加了一个多页连续展示图片的功能,经常看网络小说的读者都知道,小说的每一页都可以连续的往下翻,好像页和页之间是连起来的那样。
“改进3型”就提供了“连续”展示多页的效果,并且还可以缩放。
本文参考自Qt中的
QPdfView
的实现思路。如果你读过QPdfView
的源码,你就会发现本篇文章中的代码有雷同的点。
源码传送门:Aderversa/MultiImageViewer (github.com)
实现思路解析
在这里我提炼一下关于“改进2型”的实现思路:
假设我们要展示的图片的左上角是它的原点,以此为原点得出一个坐标系,下图的绿色部分即是图片,而绿色矩形左上角就是图片坐标系的原点:
我们规定好viewport的移动返回,让viewport只在绿色区域中移动,保证它不出界。这样就得到一个可以移动观察的图片查看器。
当缩放操作发生的时候,我们根据缩放重新计算viewport的位置,这样就得到一个可以移动+缩放的图片查看器。
合理使用
image.translate()
我们就可以达成让viewport移动的效果,具体原理请看Qt-图片查看器-改进1型-CSDN博客
现在,我们试图让多个图片在同一个viewport中展示出来,并且还可以连续滚动,如何实现呢?
参考QPdfView
的实现,我得出了这样一种抽象:我们要展示的多个图片构成的整体,其实也是一张图片,只不过这个“图片”由多个图片构,我称之为虚拟图片。
通过自定义一个虚拟图片坐标系,将图片安排到指定的坐标位置上,同时制定好这个虚拟图片的大小,我们最终就可以得出这样一张虚拟图片:
绿色框即是我们的虚拟图片的范围,而蓝色框是viewport的范围。当我们的需要绘制viewport的内容时,我们就会查看哪个image对应的坐标rect和viewport对应的rect相交,发现相交的image,我们就把它画出来。你当然也可以全部都画,毕竟不与viewport相交的都展示不出来,但这样会浪费性能,特别是要图片有几百张的时候,无效绘制会很多。
个人感觉难的是对于缩放和虚拟坐标的运算,还有viewport的定位。我一开始对这些东西不熟悉,不知道从哪开始,完成了“改进1型”和“改进2型”之后才逐渐找到完成这个多页连续展示的整体思路。
核心代码实现
在本篇文章中,我使用QAbstractScrollArea
提供的水平和垂直滚动条的值来定位viewport左上点。滚动条的移动返回即viewport的坐标范围,通过简单的数学运算,我们就可以得出水平和垂直的移动范围:
void MultiImageViewer::updateScrollBars()
{
const QSize p = viewport()->size();
const QSize v = m_documentLayout.documentSize; // 虚拟图片的大小
horizontalScrollBar()->setRange(0, v.width() - p.width());
horizontalScrollBar()->setPageStep(p.width());
verticalScrollBar()->setRange(0, v.height() - p.height());
verticalScrollBar()->setPageStep(p.height());
}
如何设定viewport呢?其实也非常简单,QAbstractScrollArea::viewport()->size()
提供了我们的viewport的大小(这里你可以直接把viewport()看做一个画布,不过它的大小会随着窗口的大小而变化)。而我们可以通过滚动条获得左上点的坐标,那viewport的rect不就出来了吗?
void MultiImageViewer::calculateViewport()
{
const int x = horizontalScrollBar()->value();
const int y = verticalScrollBar()->value();
const int width = viewport()->width();
const int height = viewport()->height();
setViewport(QRect(x, y, width, height));
}
void MultiImageViewer::setViewport(QRect viewport)
{
if (m_viewport == viewport)
return;
const QSize oldSize = m_viewport.size();
m_viewport = viewport;
if (oldSize != m_viewport.size()) { // viewport大小发生变化,调整虚拟布局
updateDocumentLayout();
}
}
这里viewport大小变化之后需要调整虚拟图片的坐标布局,是因为:当viewport的大小太大时,我们的margins可能不够让图片居中,所以需要利用viewport本身的大小来计算新的margins;当viewport太小时,我们又必须保持margins。
并且由于,虚拟布局的大小和viewport大小决定了滚动条的范围,所以这里还会更新滚动条。
这里的重点在于,我们如何构造这样一个虚拟布局,QPdfView
中功能很多,所以搭建虚拟布局的代码很长,我们这里取其精华,得到了一个简化版的虚拟布局构建算法:
MultiImageViewer::DocumentLayout MultiImageViewer::calculateDocumentLayout() const
{
DocumentLayout documentLayout;
// qreal是缩放因子,后续渲染的时候需要根据这个值来缩放原图
QHash<int, QPair<QRect, qreal>> pageGeometryAndScale;
const int pageCount = m_document.size();
int totalWidth = 0;
const int startPage = 0;
const int endPage = pageCount;
// 只是计算页面本身的大小
for (int page = startPage; page < endPage; ++page) {
QSize pageSize;
qreal pageScale = m_zoomFactor;
// 计算出缩放后的大小
pageSize = m_document[page].size() * m_zoomFactor;
// 考虑到不同页面的宽度可能不一,这里取大的页面的宽度
totalWidth = qMax(totalWidth, pageSize.width());
pageGeometryAndScale[page] = {QRect(QPoint(0,0), pageSize), pageScale};
}
// margins也会占用一定的viewport宽度
totalWidth += m_documentMargins.left() + m_documentMargins.right();
// 第一个页面的top的起始y坐标
int pageY = m_documentMargins.left();
// 根据页面的大小,为每个页面定义一个自定义坐标系中的位置
for (int page = startPage; page < endPage; ++page) {
const QSize pageSize = pageGeometryAndScale[page].first.size();
// 如果viewpor.width > totalWidth,说明margins.left不能让页面刚好居中,应该取(viewport.width() - pageSize.width() )/ 2
// 这样相当于调整了margins.left,让页面居中
// 这样看来,在viewport太大的时候,totalWidth只是作为一层参考?
const int pageX = (qMax(totalWidth, m_viewport.width()) - pageSize.width()) / 2;
pageGeometryAndScale[page].first.moveTopLeft(QPoint(pageX, pageY));
pageY += pageSize.height() + m_pageSpacing;
// m_pageSpacing是垂直层面上,页面之间的距离。
}
pageY += m_documentMargins.bottom();
documentLayout.pageGeometryAndScale = pageGeometryAndScale;
documentLayout.documentSize = QSize(totalWidth, pageY);
return documentLayout;
}
简单理解就是:先通过缩放因子计算组成虚拟图片的image的size,然后让这些image竖直排队,中间要有距离。
那么有了这个布局之后,我们的绘图操作就简化了非常多了,因为,我们不再需要将复杂的坐标计算放到paintEvent中进行:
void MultiImageViewer::paintEvent(QPaintEvent * event)
{
QPainter painter(viewport());
// 将背景涂黑,区分页面和背景
painter.fillRect(event->rect(), palette().brush(QPalette::Dark));
// 定位viewport
painter.translate(-m_viewport.x(), -m_viewport.y());
for (auto it = m_documentLayout.pageGeometryAndScale.cbegin();
it != m_documentLayout.pageGeometryAndScale.cend(); ++it) {
const QRect pageGeometry = it.value().first; // 前面计算得出:图片在虚拟图片的rect坐标
const qreal scale = it.value().second; // 该图片的缩放因子,上面的rect坐标正是通过这个原图和该因子得出来的
if (pageGeometry.intersects(m_viewport)) {
painter.fillRect(pageGeometry, Qt::white);
const int page = it.key();
// pageGeometry是缩放后的图片的大小,这里匹配缩放
QImage img = m_document[page].scaled(m_document[page].size() * scale);
painter.drawImage(pageGeometry, img);
}
}
}