深度定制 Qt 树形控件选中样式

试验环境:

  • Windows 11
  • Qt 5.15.2

特别说明试验环境是有原因的。Qt 开发的程序在不同操作系统上有不同的显示效果——即风格,这比较好理解。但是,不同版本的 Qt 在同一操作系统上的显示效果也是有不同的。以树形控件为例,Qt 5.15.2 和 6.7.1 版本下 selectItems 行为的选中样式是不同的,要注意版本升级问题,这是我踩过的坑。

先看效果对比图:

左边的树是 QTreeWidget 默认的选中样式,右边的树是定制后的选中效果。主要有以下几个不同点:

  • 选中行的前景色,也就是文本颜色
  • 选中行的背景色
  • 选中范围
  • 行高

接下来说明具体实现。

1. 修改 QTreeWidget 控件 UI 参数

1.1 修改 selectionBehavior 值为 SelectItems

修改后的选中范围由整行变成了仅有内容区域,虽然不是最终效果,但是它提供了自定义内容区域矩形的可能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// qcommonstyle.cpp
// QCommonStyle::drawPrimitive(PrimitiveElement pe, const QStyleOption* opt, QPainter* p, const QWidget* w) const override
case PE_PanelItemViewItem:
if (const QStyleOptionViewItem *vopt = qstyleoption_cast<const QStyleOptionViewItem *>(opt)) {
QPalette::ColorGroup cg = (widget ? widget->isEnabled() : (vopt->state & QStyle::State_Enabled))
? QPalette::Normal : QPalette::Disabled;
if (cg == QPalette::Normal && !(vopt->state & QStyle::State_Active))
cg = QPalette::Inactive;

if (vopt->showDecorationSelected && (vopt->state & QStyle::State_Selected)) {
p->fillRect(vopt->rect, vopt->palette.brush(cg, QPalette::Highlight)); // selectionBehavior = SelectRows 时走这里,矩形是固定的
} else {
if (vopt->backgroundBrush.style() != Qt::NoBrush) {
QPointF oldBO = p->brushOrigin();
p->setBrushOrigin(vopt->rect.topLeft());
p->fillRect(vopt->rect, vopt->backgroundBrush);
p->setBrushOrigin(oldBO);
}

if (vopt->state & QStyle::State_Selected) { // selectionBehavior = SelectItems 时走这里,矩形由虚函数 subElementRect 决定
QRect textRect = subElementRect(QStyle::SE_ItemViewItemText, opt, widget);
p->fillRect(textRect, vopt->palette.brush(cg, QPalette::Highlight));
}
}
}
break;

后边代码实现里可以看到,正是通过重写 subElementRect 虚函数调整相关矩形的。

1.2 修改调色板

Highlight 项是选中行背景色,HighlightText 是选中行文本颜色。

背景色一定不要用 qss 去配置!如果有

1
2
3
QTreeView::item:selected {
background-color: red;
}

这样的声明,一定把 background-color 去掉。原因见下面代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
// qstylesheetstyle.cpp
// void QStyleSheetStyle::drawPrimitive(PrimitiveElement pe, const QStyleOption *opt, QPainter *p, const QWidget *w) const
if (pseudoElement != PseudoElement_None) {
QRenderRule subRule = renderRule(w, opt, pseudoElement);
if (subRule.hasDrawable()) {
subRule.drawRule(p, rect); // 有 qss 背景时走这个分支
} else {
baseStyle()->drawPrimitive(pe, opt, p, w); // 没有 qss 背景时走这个分支
}
} else {
baseStyle()->drawPrimitive(pe, opt, p, w);
}

qss 背景被认为是 drawable,它的渲染矩形即 rect 宽是整行宽度,而非仅有内容区域。

1.3 修改 icon 选中模式

同 palette 一样,一个 QIcon 对象是一组状态的集合。默认选中状态下,会有一个暗色的掩码覆在图标文件之上(效果如 1.1 配图),这通常不是期望的效果。可以按下图把 Selected 两个分量值修改。

QIcon 对应的编程接口,可以查阅文档或看一下 .ui 生成的 C++ 代码。

2. 自定义 style 类

2.1 自定义 style 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
static const int MARGIN = 8;

class BgStyle : public QCommonStyle
{
public:
int pixelMetric(PixelMetric metric, const QStyleOption* option, const QWidget* widget) const override
{
if (metric == QStyle::PM_FocusFrameHMargin) // 水平 margin,影响图标、文本的显示宽度。垂直 margin 用不到,高度用 sizeFromContents 控制
{
return MARGIN;
}
return QCommonStyle::pixelMetric(metric, option, widget);
}

QSize sizeFromContents(ContentsType ct, const QStyleOption* opt, const QSize& contentsSize, const QWidget* w) const override
{
auto size = QCommonStyle::sizeFromContents(ct, opt, contentsSize, w);
if (ct == QStyle::CT_ItemViewItem) // QStyledItemDelegate::sizeHint 中调用,决定行高度,修改宽度没意义
{
size.rheight() += 2 * MARGIN;
}
return size;
}

QRect subElementRect(SubElement subElement, const QStyleOption* option, const QWidget* widget) const override
{
auto rect = QCommonStyle::subElementRect(subElement, option, widget);
if (subElement == SE_ItemViewItemDecoration) // 装饰(即图标)
{
rect.adjust(-MARGIN, 0, -MARGIN, 0); // 因为调整过 PM_FocusFrameHMargin 参数值,导致图标左边 margin 也变大,这里把图标往左靠了一下
}
else if (subElement == SE_ItemViewItemText) // 文本
{
// 水平方向上,跟着图标一起往左靠。同时把图标和文本之间的 margin 也去掉,所以左移了两倍的 MARGIN
// 垂直方向上,把上沿调高,下沿调低
rect.adjust(-2 * MARGIN, -MARGIN, -2 * MARGIN, MARGIN);
}
return rect;
}

void drawPrimitive(PrimitiveElement pe, const QStyleOption* opt, QPainter* p, const QWidget* w) const override
{
if (pe == PE_FrameFocusRect) // 不画默认焦点框
{
return;
}
QCommonStyle::drawPrimitive(pe, opt, p, w);
}
};

调用:

1
2
// 没有管 BgStyle 对象的所有权,不要照抄。
ui.treeWidget->setStyle(new BgStyle());

3. 吐槽

Qt 的 Style 是一套非常非常复杂的系统,下边是 Style 类的关系图。

在有相关 qss 配置时,控件实际持有的是 QStyleSheetStyle 类型的对象;没有 qss 配置时,依运行操作系统不同,可能是 “windows” 或 “windowsvista” 风格。

你可能想从 QWindowsVistaStyleQStyleSheetStyle 派生出一个子类,微调其中一些效果,但这是做不到的——QWindowsStyle 及其子类,对用户是不可见的,即开发者最多只能从 QCommonStyle 类继承。

然后你想到利用 QProxyStyle,为现有 Style 类设置一个代理,改写其个别行为,似乎比重写 QCommonStyle 类更方便。但实测发现,QProxyStyle::sizeFromContents 总是不能触发,导致不能调整行高。

把类关系理清地差不多了,还有具体的渲染过程——尤其是对树形控件这类 ItemView,渲染控制分散在了 View、ItemDelegate、qss、Style 里,可渲染的元素又被层层分解,控件、行、indicator、图标、文本、选中态、焦点态,逻辑无处不在;有些又互有影响,需要找准上层的统一修改入口。

评论