原文地址:Advanced GTK Techniques。
译文将 Widget 理解为为“控件”,Container 理解为“容器”,Method 理解为“方法/函数”。
自定义容器
对于 GTK 而言,(控件的)空间分配并不是一个十分困难的任务。GTK 中的控件会根据从属关系自下而上给出自身的偏好尺寸,包含它们的容器据此为子控件做出分配,并计算出自己的偏好尺寸,然后继续向上层反馈。这一过程通过调用 gtk_widget_get_preferred_height()
和 gtk_widget_get_preferred_width()
即可实现;此外,GTK 还提供了其它函数方便人们通过获取的宽度确定高度,亦或通过高度确定宽度。知晓了各个控件的偏好尺寸后,容器便会自上而下开始分配工作,通过 gtk_widget_size_allocate()
为其子控件开辟空间。由此可知,如果开发者重写了这个函数,便可实现容器对控件尺寸分配方法的控制。
上述过程与 GTK 2 的工作原理并不相符,有关 GTK 2 的讲解可以查阅网络上的其它资源。
在大多数情形中——比如创建一个组合控件或者向已有的容器添加功能,你可以通过编写一个容器的子类来实现自定义容器的自定义(例如编写一个 GtkGrid
的子类),这样就不需要直接面对如何处理控件尺寸分配或者其它棘手的问题,而是通通交给它的父类容器解决。但是如果需要一个能够以不同于现有 GTK 容器处理方式的部件时又该怎么办?这就需要我们创造一个新的容器类型,并自行设计它的尺寸分配算法。
可惜的是,目前采用这种方法的实例往往过于复杂,不利于教学,因此我们将会编写一个几乎没什么用的容器,它将所有的子控件放置在一个“表格”中,每个控件占用一个格子,颇有种 80 年代影视剪辑中分屏效果的风味,就像 Heat of the Moment 一样。这个容器会查找整数 n
使得:
式中 V
表示容器中可见的子控件数量。这个容器将会被分割成 n × n
的表格,从左向右、从上向下依次填充。
头文件
我们将创建一个名为 PSquare
的容器,它是 GtkContainer
的子类。PSquare
的头文件 psquare.h
在之前的教学中已有涉及,就不在此赘述。头文件仅会输出两个函数:p_square_get_type()
和 p_square_new()
,代码中的有趣之处在于重写 GtkContainer
函数的部分。
类样板
我们将简单介绍文件 psquare.c
中涉及到 GObject
的知识点。了解 G_DEFINE_TYPE
宏和 g_type_class_add_private()
的机制对我们而言有益无害,而且并不是每篇教程都会讲这些。
// psquare/psquare.c
G_DEFINE_TYPE(PSquare, p_square, GTK_TYPE_CONTAINER);
G_DEFINE_TYPE
宏非常有用,它可以免除你很多的输入工作。它为 PSquare
创建了在头文件中声明过的 p_square_get_type()
函数、定义了名为 p_square_parent_class
的局部变量;此外,还声明了 p_square_class_init()
和 p_square_init()
两个局部函数。
// psquare/psquare.c
static void
p_square_class_init(PSquareClass *klass)
{
/* Override GtkWidget methods */
GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass);
widget_class->get_preferred_width = p_square_get_preferred_width;
widget_class->get_preferred_height = p_square_get_preferred_height;
widget_class->size_allocate = p_square_size_allocate;
/* Override GtkContainer methods */
GtkContainerClass *container_class = GTK_CONTAINER_CLASS(klass);
container_class->child_type = p_square_child_type;
container_class->add = p_square_add;
container_class->remove = p_square_remove;
container_class->forall = p_square_forall;
/* Add private indirection member */
g_type_class_add_private(klass, sizeof(PSquarePrivate));
}
在函数 p_square_class_init()
中引入 parent_class
变量是为了避免每次链接父类时都调用 g_type_class_peek_parent()
(常出现于 finalize
过程中)。你需要为自己类和实例编写初始化函数。
类初始化函数 p_square_class_init
重写了多个父类的方法。如果你不熟悉这种 “GObject
式”的实现过程,不妨现在就了解一下。我们的测试程序不会用到类中的 forall
和 child_type
方法,但是浏览 gtkcontainer.c
后可以看到二者在父类中均被设置为 NULL
——不折不扣的“虚”函数,所以也需要重写。
为了找出父类的哪些方法需要被重写,你不得不浏览
GtkContainer
的源代码,从 API 文档中并不能很好地做出分辨。
类初始化函数做的另外一件事是注册一个 PSquare
的私有成员,这通常是隐藏类中实现方法细节最有效的方式。代码中的 g_type_class_add_private()
会定义私有成员的结构,并通知 GObject
为类的每一个实例分配私有成员内存。这样一来,每个实例就都有了私有成员部分,而你只能通过编写接口函数实现对它们的访问。私有结构通过如下代码定义:
// psquare/psquare.c
#define P_SQUARE_PRIVATE(obj) (G_TYPE_INSTANCE_GET_PRIVATE((obj), P_SQUARE_TYPE, PSquarePrivate))
typedef struct _PSquarePrivate PSquarePrivate;
struct _PSquarePrivate
{
GList *children;
};
或许将这些代码专门放入类似于名为 psquare-private.h
的文件中是个更好的选择。这样,任何包含它的文件将可以访问私有成员,类似于 C++ 中的友元数据。如果你编写的类体积巨大,这么做也有助于明晰结构。
P_SQUARE_PRIVATE()
宏可以返回 PSquare
的私有成员。编写的例子中仅有一个私有成员数据,我们需要自己去维护它。
译者注:在很多实际的 GTK 程序中,更为常见的私有成员实现方法为在 psquare/psquare.c 中输入:
G_DEFINE_TYPE_WITH_PRIVATE(PSquare, p_square, GTK_TYPE_CONTAINER);
。这将直接声明一个带私有成员PSquarePrivate
的类,无需再在类初始化函数中引入g_type_class_add_private()
。
通过这一宏定义类后,在函数中调取私有成员可直接使用PSquarePrivate *priv = p_square_get_instance_private()
函数,因此P_SQUARE_PRIVATE()
也不需要了。
实例初始化函数 p_square_init()
负责将公有和私有成员实例化:
// psquare/psquare.c
static void
p_square_init(PSquare *square)
{
gtk_widget_set_has_window(GTK_WIDGET(square), FALSE);
/* Initialize private members */
PSquarePrivate *priv = P_SQUARE_PRIVATE(square);
priv->children = NULL;
}
gtk_widget_set_has_window()
值为 FALSE
意味着 PSquare
不具有 GdkWindow
,因此我们不会自己完成绘制操作,而只是负责组织容器的子控件。正如代码中展示的,私有成员 children
初始化为 NULL
——一个空的 GList
。
还有一件约定俗成的事情是你需要知道的:编写的控件通常通过 XXXX_new(p_square_new()
)函数产生,其返回值为一个新的类的实例,并强制转换为 GtkWidget
格式:
// psquare/psquare.c
GtkWidget *
p_square_new()
{
return GTK_WIDGET(g_object_new(p_square_get_type(), NULL));
}
重写 GtkContainer 方法
现在我们介绍如何重写父类的方法。GtkContainer
是一个非常基本的类,因此我们就从它入手。
child_type
方法用于确定哪些类型的子控件可以被装入容器中,这一项我们只要返回 GTK_TYPE_WIDGET
就好了。GtkContainerClass
结构中的 forall
指针并未同一个 GtkContainer
方法对应,而是在程序运行过程中用于与 gtk_container_forall()
和 gtk_container_foreach()
对接。这两个函数的前者意味着将对容器中包括“内部”成员在内的所有成员执行操作(译者注:“内部”成员指并非由用户添加的控件,而是由容器创建,例如对话框中自动生成的按钮),后者则会跳过“内部”成员。forall
函数中会读取一个是否包括“内部”成员的标记,我们的 PSquare
没有“内部”成员,所以直接忽略它即可。
// psquare/psquare.c
static void
p_square_forall(GtkContainer *container, gboolean include_internals, GtkCallback callback, gpointer callback_data)
{
PSquarePrivate *priv = P_SQUARE_PRIVATE(container);
g_list_foreach(priv->children, (GFunc)callback, callback_data);
}
下一项工作是实现 add
和 remove
方法。需要注意的是,你并非一定需要重写这两个方法,仅在例如容器需要与父类不同的添加控件方法时才会这么做。GtkContainer
类中这两个方法默认情况下不会做任何事,而是发出一个“方法未被实现”的警告。
// psquare/psquare.c
static void
p_square_add(GtkContainer *container, GtkWidget *widget)
{
g_return_if_fail(container || P_IS_SQUARE(container));
g_return_if_fail(widget || GTK_IS_WIDGET(widget));
g_return_if_fail(gtk_widget_get_parent(widget) == NULL);
PSquarePrivate *priv = P_SQUARE_PRIVATE(container);
/* Add the child to our list of children.
* All the real work is done in gtk_widget_set_parent(). */
priv->children = g_list_append(priv->children, widget);
gtk_widget_set_parent(widget, GTK_WIDGET(container));
/* Queue redraw */
if(gtk_widget_get_visible(widget))
gtk_widget_queue_resize(GTK_WIDGET(container));
}
你可能会对 p_square_add
的简短感到惊讶,但正如注释中所述,所有核心的工作都在 gtk_widget_set_parent()
中完成:添加引用计数、重绘控件、触发相应的信号,等等。这之后我们仍需要自行绘制容器。移除子控件的方法与此类似,一切核心工作都在 gtk_widget_unparent()
中完成。
// psquare/psquare.c
static void
p_square_remove(GtkContainer *container, GtkWidget *widget)
{
g_return_if_fail(container || P_IS_SQUARE(container));
g_return_if_fail(widget || GTK_IS_WIDGET(widget));
PSquarePrivate *priv = P_SQUARE_PRIVATE(container);
/* Remove the child from our list of children.
* Again, all the real work is done in gtk_widget_unparent(). */
GList *link = g_list_find(priv->children, widget);
if(link) {
gboolean was_visible = gtk_widget_get_visible(widget);
gtk_widget_unparent(widget);
priv->children = g_list_delete_link(priv->children, link);
/* Queue redraw */
if(was_visible)
gtk_widget_queue_resize(GTK_WIDGET(container));
}
}
这里有一点让人欣慰:我们不需要编写 p_square_destroy()
或者 p_square_finalize()
方法,GtkContainer
会在容器析构时自动处理这些。
译者注:一般而言,在实例创建过程中,
GtkWidget
通常被GtkContainer
包含,层层递进,GTK 会自动处理它们的释放过程,不需要用户花费精力;而对于用户在XxxxPrivate
中添加的其它私有成员,则可能需要手动管理内存释放的问题。
空间规划
我们已经完成了一切准备工作,现在我们开始重点部分。我们需要确定容器中每添加一个格子时容器的宽度(或高度)需要增加多少。本示例中,格子的宽度与容器中最宽的子控件相等。格子的高度将采用相似的方式确定。此外,我们也需要考虑 GtkContainer
的 border_width
属性带来的影响。
// psquare/psquare.c
static void p_square_get_preferred_width(GtkWidget *widget, int *minimal, int *natural);
static void p_square_get_preferred_height(GtkWidget *widget, int *minimal, int *natural);
容器的类中有两个返回自身尺寸的函数:一个返回宽度,一个返回高度。它们的参数中有两个指向整数的指针,一个必须填写尺寸的最小值,另一个填写默认值。
在本示例中,这两个函数的运作方式近乎相同,所以我们将它们封装到一个函数中—— get_size()
。这个函数有一个额外的 GtkOrientation
参数,当我们需要输入宽度时,将其设置为 GTK_ORIENTATION_HORIZONTAL
,需要输入高度时则设置为 GTK_ORIENTATION_VERTICAL
。
// psquare/psquare.c
static void get_size(PSquare *self, GtkOrientation direction, int *minimal, int *natural);
我们调用两次 get_size()
函数,分别设置格子的宽度和高度。随后,我们计算容器的尺寸,也就是表格的行数和列数,由于容器是一个正方形,所以用一个 n_groups
表示表格的边长即可。如果 n_groups
为零,就直接返回。
随后,我们遍历各个子控件,通过 get_group_sizes()
获取它们的尺寸。容器的最小尺寸和默认尺寸都存储在 GtkRequestedSize
结构中。一旦获得了这些信息,我们就将它们累加,得到总大小。
// psquare/psquare.c
static void
get_size(PSquare *self, GtkOrientation direction, int *minimal, int *natural)
{
/* Start with the container's border width */
unsigned border_width =
gtk_container_get_border_width(GTK_CONTAINER(self));
*minimal = *natural = border_width * 2;
/* Find out how many children there are */
unsigned n_groups = get_n_columns_and_rows(self);
if(n_groups == 0)
return;
/* Find out how much space they want */
GtkRequestedSize *sizes = get_group_sizes(self, direction, n_groups);
/* Add the widths and pass that as the container's width */
unsigned count;
for(count = 0; count < n_groups; count++) {
*minimal += sizes[count].minimum_size;
*natural += sizes[count].natural_size;
}
g_free(sizes);
}
函数 get_n_columns_and_rows()
如下所示。注意容器中可能会有不可见的子控件,PSquare
并不会为它们分配位置,所以 n_groups
有可能在还有子控件时被设置为零。
// psquare/psquare.c
unsigned
get_n_columns_and_rows(PSquare *self)
{
PSquarePrivate *priv = P_SQUARE_PRIVATE(self);
/* Count the visible children */
unsigned n_visible_children = 0;
g_list_foreach(priv->children, (GFunc)count_visible_children,
&n_visible_children);
if(n_visible_children == 0)
return 0;
/* Calculate the number of columns */
return (unsigned)ceil(sqrt((double)n_visible_children));
}
// psquare/psquare.c
/* Convenience function for counting the number of visible
* children, for use with g_list_foreach() */
static void
count_visible_children(GtkWidget *widget, unsigned *n_visible_children)
{
if(gtk_widget_get_visible(widget))
(*n_visible_children)++;
}
接下来我们讲解 get_group_sizes()
,这是空间计算最为核心的工作。
首先,我们创建一个数组来存储所有的控件组信息(即列的宽度或行的高度)。在获取了所有子控件的偏好尺寸后,我们找出每组信息中的最大值。
// psquare/psquare.c
GtkRequestedSize *sizes = g_new0(GtkRequestedSize, n_groups);
随后我们再次遍历所有子控件,询问它们的偏好尺寸。如果设置了宽度模式,我们获取宽度,反之获取高度。这一过程中我们引入一个变量 group_num
,它在宽度模式中记录当前子控件的行号,在高度模式中记录列号。
随后我们获取每组尺寸的最大值:如果子控件的尺寸大于所属组的值,就把组值替换。完成这些工作后,我们返回 sizes
数组。
// psquare/psquare.c
unsigned count = 0;
GList *iter;
for(iter = priv->children; iter; iter = g_list_next(iter)) {
if(!gtk_widget_get_visible(iter->data))
continue;
int child_minimal, child_natural;
unsigned group_num;
if(direction == GTK_ORIENTATION_HORIZONTAL) {
gtk_widget_get_preferred_width(iter->data,
&child_minimal, &child_natural);
group_num = count % n_groups;
} else {
gtk_widget_get_preferred_height(iter->data,
&child_minimal, &child_natural);
group_num = count / n_groups;
}
sizes[group_num].minimum_size =
MAX(child_minimal, sizes[group_num].minimum_size);
sizes[group_num].natural_size =
MAX(child_natural, sizes[group_num].natural_size);
count++;
}
空间分配
size_allocate
方法的执行与上述过程相似。它接收一个 GtkAllocation
结构体,其中包含了控件必须具有的尺寸大小信息。
// psquare/psquare.c
static void p_square_size_allocate(GtkWidget *widget, GtkAllocation *allocation);
我们首先需要将尺寸分配信息写入控件:
// psquare/psquare.c
gtk_widget_set_allocation(widget, allocation);
下一步的工作与 size_request
方法十分相似。首先我们获取行数和列数:
// psquare/psquare.c
unsigned n_columns, n_rows;
n_columns = n_rows = get_n_columns_and_rows(P_SQUARE(widget));
if(n_columns == 0)
return;
n_columns
与 n_rows
事实上是相同的,但为了便于理解还是在代码中区别对待。再一次地,如果容器中没有可见的子控件,我们就直接返回。
现在我们需要将容器开辟的空间分配给它的子控件。我们的策略是在将额外的宽度平均分配到每一列,将额外的高度平均分配到每一行。如果剩余空间过小,就反过来从每行或每列抽取相等的长度分配给新的控件。首先我们计算出每列每行空间富余或缺少的长度,并用两个变量来表示: extra_width
和 extra_height
。它们的初始值为总宽度/高度,随后将会减去我们需要的长度。首先需要减去的是容器的边界宽度:
// psquare/psquare.c
unsigned border_width =
gtk_container_get_border_width(GTK_CONTAINER(widget));
int extra_width = allocation->width - 2 * border_width;
int extra_height = allocation->height - 2 * border_width;
随后我们通过上面提到的 get_group_sizes()
函数获得每列的宽度。通过计算,我们为每列添加额外长度(这个值可能是负数),得出每列的实际宽度。这些工作在函数 distribute_extra_space()
中完成,我们将在稍后介绍它。
完成列宽的工作后,我们继续计算每行的高度。二者过程近乎相同,除了用一个名为 get_group_sizes_for_sizes()
的函数替换 get_group_sizes()
,它能够为已知宽度分配适宜的高度,反之亦然。我们在此不再给出这个函数的代码,它与 get_group_sizes()
的区别在于将 gtk_widget_get_preferred_height()
和 ..._width()
替换成了 gtk_widget_get_preferred_height_for_width()
和 ..._width_for_height()
。你可以在 psquare.c
文件中查看它的实现。
// psquare/psquare.c
/* Follow the same procedure as in the size request to get
* the ideal sizes of each column */
GtkRequestedSize *widths = get_group_sizes(P_SQUARE(widget),
GTK_ORIENTATION_HORIZONTAL, n_columns);
/* Distribute the extra space per column (can be negative) */
unsigned count;
for(count = 0; count < n_columns; count++)
extra_width -= widths[count].minimum_size;
distribute_extra_space(P_SQUARE(widget), widths, extra_width, n_columns);
/* Follow the same procedure for height,
* now that we know the width */
GtkRequestedSize *heights = get_group_sizes_for_sizes(P_SQUARE(widget),
GTK_ORIENTATION_VERTICAL, widths, n_rows);
/* Distribute the extra space per row (can be negative) */
for(count = 0; count < n_rows; count++)
extra_height -= heights[count].minimum_size;
distribute_extra_space(P_SQUARE(widget), heights, extra_height, n_rows);
接下来的函数名为 distribute_extra_space()
,它将额外空间(可能为负数)分配给每个组(即行或列)。GTK 已经提供了一个便捷的函数,可以用于赋予一组控件额外的空间(但必须为非负数),以使得每个控件都能获取尽可能多的空间。当额外空间值非负时,我们可以直接调用这个函数。如果仍有空间剩余,或者第一个位置空间不足,我们将会把富余或短缺的空间均分给组内每个成员。需要注意我们不能把子控件安放在一个空间小于零的格子中,因此如果出现了这样的情况,我们需要从其它行列中“挪用”一些像素,直到值达到零为止。
// psquare/psquare.c
static void
distribute_extra_space(PSquare *self, GtkRequestedSize *sizes,
int extra_space, unsigned n_groups)
{
if(extra_space > 0) {
extra_space = gtk_distribute_natural_allocation(extra_space,
n_groups, sizes);
}
unsigned count;
int extra_per_group = extra_space / (int)n_groups;
for(count = 0; count < n_groups; count++) {
sizes[count].minimum_size += extra_per_group;
/* If this results in a negative width, redistribute
* pixels from other nonzero-width columns to this one */
if(sizes[count].minimum_size < 0) {
unsigned count2;
for(count2 = (count + 1) % n_groups;
sizes[count].minimum_size < 0;
count2++, count2 %= n_groups)
{
if(count2 == count || sizes[count2].minimum_size < 0)
continue;
sizes[count2].minimum_size--;
sizes[count].minimum_size++;
}
}
}
}
回到本章核心——空间分配。我们设立一个点 (x, y)
用于记录下一个子控件放置位置的左上点座标。需要注意的是 GtkAllocation
结构中的 x
和 y
记录的是整个屏幕的座标(原作者是这样认为的),而不是从容器的左上角开始计算,所以你需要做出一些调整。
// psquare/psquare.c
/* Start positioning the items at the container's origin,
* less the border width */
int x = allocation->x + border_width;
int y = allocation->y + border_width;
随后我们再次遍历可见的子控件。我们会在栈中为每个子控件分配一个包含了座标 (x, y)
以及宽高信息的 GtkAllocation
结构体,然后通过 gtk_widget_size_allocate()
函数应用这些设置。接着,我们需要更新下一个子控件的座标,在完成了一行的分配后还需下移一行并返回新行左上角座标。
// psquare/psquare.c
count = 0;
GList *iter;
for(iter = priv->children; iter; iter = g_list_next(iter)) {
if(!gtk_widget_get_visible(iter->data))
continue;
/* Give the child its allocation */
GtkAllocation child_allocation;
child_allocation.x = x;
child_allocation.y = y;
child_allocation.width = widths[count % n_columns].minimum_size;
child_allocation.height = heights[count / n_columns].minimum_size;
gtk_widget_size_allocate(iter->data, &child_allocation);
/* Advance the x coordinate */
x += child_allocation.width;
count++;
/* If we've moved to the next row, return the x coordinate
* to the left, and advance the y coordinate */
if(count % n_columns == 0) {
x = allocation->x + border_width;
y += child_allocation.height;
}
}
范例程序
我们将提供一个简单的范例程序来展示 PSquare
容器的效果。你可以下载 test-psquare.c
和它的 Makefile
自行编译。我们首先创建一个 toplevel
窗口,并用 gtk_widget_set_size_request()
设定它的大小(不过在程序运行中你仍能够扩大窗口)。在一开始只有少量控件时,容器中的空间相对充足;随着控件越来越多地挤进来,它们就不得不开始缩小尺寸以适应新的布局。
我们为窗口添加了一个具有新增控件按钮和移除控件按钮工具栏,这样可以手动添加控件(gtk_container_add()
)或者移除最近添加的一个控件(gtk_container_remove()
)。
编译并运行程序后,你就可以尝试添加很多控件。你会注意到,一个 GtkEntry
会占用很多宽度,所以包含了 GtkEntry
的列往往会在空间紧张时将其它列占用,不过,即使分配了负的尺寸,理论上你也并不会收到警告。
练习
修改
p_square_size_allocate()
,使得它在分配空间时能够根据每列占总宽度比例进行规划。比如,如果空间不足,原先较宽的列将腾出较多的空间,而较窄的列则腾出较少的空间。修改
PSquare
的子控件排列方法,使得其能从左上角沿顺时针方向放置子控件。为
PSquare
的子控件属性添加实现方法。例如,fill-horizontal
和fill-vertical
属性,它们用于确定是否要将容器为其分配的空间沿横向或纵向全部占用。你也可以新建一个将二者封装在一起的属性来决定是否让控件占用整个空间。对于这个任务,你可以使用两个对齐选项,也可以用一个GtkAnchorType
来实现。