目标 实现一个像网易云音乐播放器一样的卡片轮播窗口

1.分析UI布局
垂直布局,该布局主要分为两个窗口,上面是显示图片的滚动窗口,下面是显示图片当前索引的索引窗口,明显上面的窗口需要占据剩余的所有空间,所以主布局是一个垂直布局,滚动窗口只是一个窗口没有任何布局,导航条窗口是水平布局,负责管理所有的导航点
void CoverFlowWidget::BuildUI()
{
// 创建根布局 垂直布局
QVBoxLayout* root = new QVBoxLayout(this);
// 创建滚动窗口
scroll_ = new QWidget(this);
scroll_->setMouseTracking(true);
scroll_->resize(780, 220);
root->addWidget(scroll_, 1); // 占据除去索引框的所有空间
// 创建圆点导航条(水平布局)
dotBar_ = new QWidget(this);
dotBar_->setMouseTracking(true);
QHBoxLayout* dotLayout = new QHBoxLayout(dotBar_);
dotLayout->setContentsMargins(0, 0, 0, 0);
dotLayout->setSpacing(10);
root->addWidget(dotBar_, 0, Qt::AlignHCenter); // 索引框在水平居中
}
1.1 滚动窗口(图片绘制)
重点在于图片位置的绘制,只会展示三张图片,那么该如何绘制这种近大远小的感觉呢?
也就是在整体的窗口大小确定时计算出三张图片在窗口中的布局显示
也就是三个矩形的位置
也就是下面这种感觉

图片堆叠的效果如下

1.1.1 确定矩形框位置和大小
首先要根据整个窗口的大小来确认
左右两侧预留够足够大小的宽度,上下两侧预留够足够大小的宽度,最后得到的是中心图片的宽度
根据中心图层的大小获取到侧边图片的大小,然后根据这三个图片的大小获取对应的矩形位置
void CoverFlowWidget::updateRects()
{
// 计算中心与侧边大小与位置(保持上下留白)
const int Width = scroll_->width(), Hight = scroll_->height();
const int margin = 12;
// 让左右各露出固定宽度(更接近参考图)
const int peek = qRound(Width * 0.17); // 左右各露出 ~17%(可调)
// 中心尺寸:整体宽度扣掉左右露出
szCenter_ = QSizeF(Width - 2 * peek, qMin(220, Hight - 2 * margin));
// 侧边比例(略小于中心)
const qreal sideScale = 0.78; // 可调 0.74~0.82
szSide_ = QSizeF(szCenter_.width() * sideScale, szCenter_.height() * sideScale);
rcCenter_ = QRectF((Width - szCenter_.width()) / 2.0,
(Hight - szCenter_.height()) / 2.0,
szCenter_.width(), szCenter_.height());
rcLeft_ = QRectF(LEFT_MARGIN,
(Hight - szSide_.height()) / 2.0,
szSide_.width(), szSide_.height());
rcRight_ = QRectF(Width - szSide_.width() - RIGHT_MARGIN,
(Hight - szSide_.height()) / 2.0,
szSide_.width(), szSide_.height());
}
1.1.2 设置过渡动画
在动画里进行实时的绘制,动画开启时会伴随着数值的改变,其实就是虚幻中的线性插值,从0到1进行插值,插值的时间就是动画的速度罢了,当动画结束后重置图片索引即可
anim_ = new QVariantAnimation(this);
anim_->setDuration(320);
anim_->setStartValue(0.0);
anim_->setEndValue(1.0);
connect(anim_, &QVariantAnimation::valueChanged, this, [this](const QVariant& v)
{
t_ = v.toReal(); update();
});
connect(anim_, &QVariantAnimation::finished, this, [this]
{
// 动画结束后,落位为新的中心
center_ = (center_ + (dir_ > 0 ? 1 : -1) + slides_.size()) % slides_.size();
t_ = 0.0; update();
});
什么时候进行一次动画的播放是用定时器控制的,当然这是自动轮播
connect(&timer_, &QTimer::timeout, this, [this]
{
if (!hovering_) next();
});
void CoverFlowWidget::next()
{
if (slides_.size() <= 1 || anim_->state() == QAbstractAnimation::Running) return;
dir_ = +1; anim_->stop(); anim_->setStartValue(0.0); anim_->setEndValue(1.0); anim_->start();
}
1.1.3 绘制UI
观察下面的绘制可以大致推断一下原理也就是说,每一帧都会绘制一张图片,而每一张图片都会带有一些偏移,这里如果不清除掉之前绘制的图片,就很容易导致出现残影的效果

所以首先对画笔进行设置
QPainter p(this);
p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform, true);
p.setCompositionMode(QPainter::CompositionMode_Source); // 覆盖目标区域
p.fillRect(rect(), Qt::transparent); // 清空画布
p.setCompositionMode(QPainter::CompositionMode_SourceOver); // 与背景自然融合
然后获取要绘制的三张图片,由于我们的轮播是从左到右进行的,而center_就是中间那张图片的索引
const int n = slides_.size(); // 获取图片的大小
int idxC = center_; // 当前图片
int idxL = (center_ - 1 + n) % n; // 左边那一张
int idxR = (center_ + 1) % n; // 右边那一张
图片移动的感觉实际上是每一帧进行绘制不同位置和大小的图片造成的视觉效果,就像翻小人书一样
得到动画中矩形的位置,目前最左边的矩形位置是不变的,中间的图片往左边偏移,右边的图片往中间进行偏移
偏移开始就是动画开始的时候,动画开始的每一帧都进行图片位置的计算
// 计算动画中的矩形(dir_ 决定谁往中心、谁往侧边)
QRectF rcC = rcCenter_, rcToSide, rcToCenter, rcStatic; // 创建三个矩形,分别对应“去侧边”、“到中心”、“静态”
int idxToSide = idxC, idxToCenter = (dir_ > 0 ? idxR : idxL), idxStatic = (dir_ > 0 ? idxL : idxR); // 获取对应的图片索引(左边的图片是保持不动的)
// 然后根据起始坐标和结束坐标做线性插值,得到动画中的矩形坐标
if (dir_ > 0)
{
// 向右:中心 -> 左;右 -> 中心;左保持
rcToSide = lerp(rcCenter_, rcLeft_, t_);
rcToCenter = lerp(rcRight_, rcCenter_, t_);
rcStatic = rcLeft_;
}
else
{
// 向左:中心 -> 右;左 -> 中心;右保持
rcToSide = lerp(rcCenter_, rcRight_, t_);
rcToCenter = lerp(rcLeft_, rcCenter_, t_);
rcStatic = rcRight_;
}
线性插值
QRectF CoverFlowWidget::lerp(const QRectF& a, const QRectF& b, qreal t) const
{
return QRectF(a.x() + (b.x() - a.x()) * t,
a.y() + (b.y() - a.y()) * t,
a.width() + (b.width() - a.width()) * t,
a.height() + (b.height() - a.height()) * t);
}
绘制UI
首先基础知识是后绘制的UI在先绘制的UI之前
最后的三张图片,实际上就是三个圆角矩形框,只需要创建绘制路径,然后将上面得到的矩形的起点拿到,最后根据图片索引获取到图片直接进行绘制即可
auto drawRounded = [&](const QPixmap& pm, const QRectF& r)
{
QPainterPath path; // 创建圆角矩形路径
path.addRoundedRect(r, radius_, radius_); // 画圆角矩形
p.save();
p.setClipPath(path); // 只在当前区域进行绘制
// 简单的“裁切铺满”效果(保持比例裁剪)
QPixmap scaled = pm.scaled(r.size().toSize(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); // 缩放图片
p.drawPixmap(QPointF(r.x(), r.y()), scaled);
p.restore();
};
// 先画静态侧边(在底层)
drawRounded(slides_[idxStatic], rcStatic);
// 再画“去侧边”的(在中层)
drawRounded(slides_[idxToSide], rcToSide);
// 最后画“到中心”的(在顶层,保证中心遮住其它)
drawRounded(slides_[idxToCenter], rcToCenter);
最后在动画没有启动时也就是一开始也会进行UI的绘制
// 未在动画中时,直接三张:左、右、中心(中心最后画)
if (!anim_->state()) {
drawRounded(slides_[idxL], rcLeft_);
drawRounded(slides_[idxR], rcRight_);
drawRounded(slides_[idxC], rcCenter_);
}
完整代码如下
void CoverFlowWidget::paintEvent(QPaintEvent*)
{
QPainter p(this);
p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform, true);
p.setCompositionMode(QPainter::CompositionMode_Source); // 覆盖目标区域
p.fillRect(rect(), Qt::transparent); // 清空画布
p.setCompositionMode(QPainter::CompositionMode_SourceOver); // 与背景自然融合
if (slides_.isEmpty()) return;
const int n = slides_.size(); // 获取图片的大小
int idxC = center_; // 当前图片
int idxL = (center_ - 1 + n) % n; // 左边那一张
int idxR = (center_ + 1) % n; // 右边那一张
// 计算动画中的矩形(dir_ 决定谁往中心、谁往侧边)
QRectF rcC = rcCenter_, rcToSide, rcToCenter, rcStatic; // 创建三个矩形,分别对应“去侧边”、“到中心”、“静态”
int idxToSide = idxC, idxToCenter = (dir_ > 0 ? idxR : idxL), idxStatic = (dir_ > 0 ? idxL : idxR); // 获取对应的图片索引(左边的图片是保持不动的)
// 然后根据起始坐标和结束坐标做线性插值,得到动画中的矩形坐标
if (dir_ > 0)
{
// 向右:中心 -> 左;右 -> 中心;左保持
rcToSide = lerp(rcCenter_, rcLeft_, t_);
rcToCenter = lerp(rcRight_, rcCenter_, t_);
rcStatic = rcLeft_;
}
else
{
// 向左:中心 -> 右;左 -> 中心;右保持
rcToSide = lerp(rcCenter_, rcRight_, t_);
rcToCenter = lerp(rcLeft_, rcCenter_, t_);
rcStatic = rcRight_;
}
auto drawRounded = [&](const QPixmap& pm, const QRectF& r)
{
QPainterPath path; // 创建圆角矩形路径
path.addRoundedRect(r, radius_, radius_); // 画圆角矩形
p.save();
p.setClipPath(path); // 只在当前区域进行绘制
// 简单的“裁切铺满”效果(保持比例裁剪)
QPixmap scaled = pm.scaled(r.size().toSize(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); // 缩放图片
p.drawPixmap(QPointF(r.x(), r.y()), scaled);
p.restore();
};
// 先画静态侧边(在底层)
drawRounded(slides_[idxStatic], rcStatic);
// 再画“去侧边”的(在中层)
drawRounded(slides_[idxToSide], rcToSide);
// 最后画“到中心”的(在顶层,保证中心遮住其它)
drawRounded(slides_[idxToCenter], rcToCenter);
// 未在动画中时,直接三张:左、右、中心(中心最后画)
if (!anim_->state()) {
drawRounded(slides_[idxL], rcLeft_);
drawRounded(slides_[idxR], rcRight_);
drawRounded(slides_[idxC], rcCenter_);
}
}
1.2 滚动窗口(自定义标签)
由于绘制的图片不支持点击状态
所以这里创建一个可支持标签,通过标签的缩放和移动来模拟绘制
1.2.1 标签的移动
确认矩形框的位置和大小,依据轮播的图片进行缩放并重新设置到标签中,标签在进行位置的移动
完整的代码如下
void CoverFlowWidget::startAnimation()
{
for (FlowLabel* label : labels_) label->hide();
const int n = slides_.size(); // 获取图片的大小
if(n < 3) return; // 至少需要三个图片才能旋转
int idxC = center_; // 当前图片
int idxL = (center_ - 1 + n) % n; // 左边那一张
int idxR = (center_ + 1) % n; // 右边那一张
// 计算动画中的矩形(dir_ 决定谁往中心、谁往侧边)
QRectF rcC = rcCenter_, rcToSide, rcToCenter, rcStatic; // 创建三个矩形,分别对应“去侧边”、“到中心”、“静态”
int idxToSide = idxC, idxToCenter = (dir_ > 0 ? idxR : idxL), idxStatic = (dir_ > 0 ? idxL : idxR); // 获取对应的图片索引(左边的图片是保持不动的)
// 然后根据起始坐标和结束坐标做线性插值,得到动画中的矩形坐标
if (dir_ > 0)
{
// 向右:中心 -> 左;右 -> 中心;左保持
rcToSide = lerp(rcCenter_, rcLeft_, t_);
rcToCenter = lerp(rcRight_, rcCenter_, t_);
rcStatic = rcLeft_;
}
else
{
// 向左:中心 -> 右;左 -> 中心;右保持
rcToSide = lerp(rcCenter_, rcRight_, t_);
rcToCenter = lerp(rcLeft_, rcCenter_, t_);
rcStatic = rcRight_;
}
// 画圆角矩形
auto drawRounded = [&](const int idx, const QRectF& r)
{
FlowLabel* label = labels_[idx];
QPixmap pm = slides_[idx];
QPixmap scaled = pm.scaled(r.size().toSize(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); // 缩放图片
label->setMaximumSize(r.size().toSize());
label->setPixmap(scaled);
//label->move(QPoint(r.x(), r.y()));
label->setGeometry(r.toRect());
label->show();
label->raise();
};
// 先画静态侧边(在底层)
drawRounded(idxStatic, rcStatic);
// 再画“去侧边”的(在中层)
drawRounded(idxToSide, rcToSide);
// 最后画“到中心”的(在顶层,保证中心遮住其它)
drawRounded(idxToCenter, rcToCenter);
// 未在动画中时,直接三张:左、右、中心(中心最后画)
if (!anim_->state()) {
drawRounded(idxL, rcLeft_);
drawRounded(idxR, rcRight_);
drawRounded(idxC, rcCenter_);
}
}
1.3 创建导航栏
![]()
当图片素材被导入时索引点也就同时添加进来了,通过更新按钮的状态来设置按钮的选中状态,所以在图片的每一次过渡动画完结的时候就可以更新按钮的状态了,如下图所示,当索引点被点击时会立即更新当前图片的索引,然后根据当前图片的索引重新计算位置即可

导航栏的代码
static QString dotStytle(R"(
QPushButton {
background-color: white;
border-radius: 5px;
}
QPushButton[selected=true] {
background-color: red;
}
QPushButton[selected=false] {
background-color: white;
}
)");
connect(anim_, &QVariantAnimation::finished, this, [this]
{
// 动画结束后,落位为新的中心
UpdateDotState(dots_[center_], false);
center_ = (center_ + (dir_ > 0 ? 1 : -1) + slides_.size()) % slides_.size();
t_ = 0.0; /*update();*/ startAnimation();
// 更新下一帧索引
UpdateDotState(dots_[center_], true);
void CoverFlowWidget::LoadSlide()
{
for (int i = 1; i <= 10; ++i)
{
addSlide(QPixmap(QString(":/Images/picturewall/%1.png").arg(i)));
}
}
void CoverFlowWidget::addSlide(const QPixmap& p)
{
slides_.push_back(p);
FlowLabel* label = new FlowLabel(this);
label->setPixmap(p);
labels_.push_back(label);
updateRects();
//update();
startAnimation();
// 添加索引
QPushButton* dot = new QPushButton(dotBar_);
dot->setFixedSize(10, 10);
dot->setStyleSheet(dotStytle);
dot->setCursor(Qt::PointingHandCursor);
dots_.push_back(dot);
dotBar_->layout()->addWidget(dot);
UpdateDotState(dots_[center_], true);
connect(dot, &QPushButton::clicked, this, [this, dot]
{
int idx = dots_.indexOf(dot);
if (idx == center_) return;
UpdateDotState(dots_[center_], false);
dir_ = (idx > center_ ? 1 : -1);
center_ = idx;
UpdateDotState(dots_[center_], true);
t_ = 0.0; startAnimation();
});
}
1.4 创建左右按钮
这两个按钮需要实时的处于最上层,也就是说,对标签进行移动时,最后要进行按钮的绘制
btnPrev_ = new QPushButton(scroll_);
btnPrev_->setFixedSize(20, 20);
btnPrev_->setIcon(style()->standardIcon(QStyle::SP_ArrowLeft));
btnPrev_->setCursor(Qt::PointingHandCursor);
btnPrev_->raise();
btnNext_ = new QPushButton(scroll_);
btnNext_->setFixedSize(20, 20);
btnNext_->setIcon(style()->standardIcon(QStyle::SP_ArrowRight));
btnNext_->setCursor(Qt::PointingHandCursor);
btnNext_->raise();
connect(btnPrev_, &QPushButton::clicked, this, [this] { prev(); });
connect(btnNext_, &QPushButton::clicked, this, [this] { next(); });
当导航栏添加完所有的dot时,这时会在过一段时间后触发布局的调整,但是滚动区域的布局也会随机的占据剩余分配的空间,这里要在调整了之后立即触发布局调整,布局调整完毕后,将按钮挪到指定的位置
dotBar_->updateGeometry();
layout()->invalidate();
layout()->activate(); // 强制布局重新计算
updateRects();
btnPrev_->move(CalcuBtnPos(true));
btnNext_->move(CalcuBtnPos(false));
计算按钮的位置
QPoint CoverFlowWidget::CalcuBtnPos(bool isLeft)
{
if (isLeft) return QPoint(LEFT_MARGIN, (height() - 20) / 2);
else return QPoint(scroll_->width() - RIGHT_MARGIN - 20, (height() - 20) / 2);
}
2. 完结
这样一个滚动窗口就做好了

最终演示效果如下

440

被折叠的 条评论
为什么被折叠?



