PAYATHON 2026

如何在 GtkGrid 中移动 widgets,使 UI 显示的位置同步更新?

排障赵师傅

先调用 remove(),再调用 attach(),已经更新了按钮的网格坐标。按钮仍显示在左上角,是因为 GtkGrid 不会为前面的空行、空列自动保留固定大小。

要得到预期的 4×8 网格,可以用设置了尺寸请求的占位控件填满空单元格。移动按钮时,先移除目标位置的占位控件,再在按钮原来的位置补上一个,让各行、各列在按钮移动后仍能保留尺寸。

为什么坐标变了,显示位置却没变?

grid.query_child(&button) 返回按钮的列号、行号,以及跨越的列数和行数。这些数据表示控件在网格中的挂接位置,并不直接对应像素坐标。GtkGrid.query_child 文档

原代码中的 Grid 始终只有一个按钮。按钮从 (0, 0) 移到 (0, 1) 后,第 0 行没有控件提供高度;继续移到 (0, 2),也不会自动留下两个高为 80 的空行。GTK 的布局实现会识别空行、空列,并在分配空间时跳过它们。GtkGridLayout 源码

下面这两项设置:

.width_request(grid_width)
.height_request(grid_height)

只指定了 Grid 整体的最小尺寸请求,没有创建 4 列、8 行,也没有规定每个单元格的大小。因此,即使 Grid 占据很大的区域,唯一的按钮仍可能显示在左上角。尺寸请求文档

如何修改代码?

保留原来的 main()、常量和 widget_size(),新增 empty_cell(),然后用下面的实现替换 build_ui()。沿用原有导入即可,但可以去掉不再需要的 Cell。

具体步骤如下:

  1. 在按钮之外的所有单元格中放入占位控件。
  2. 拖动结束后,根据当前网格坐标计算目标坐标,并将其限制在网格范围内。
  3. 移除目标位置的占位控件,将按钮移过去,再补上原位置的占位控件。
fn empty_cell() -> gtk::Box {
    gtk::Box::builder()
        .width_request(CELL_SIZE)
        .height_request(CELL_SIZE)
        .build()
}

fn build_ui(app: &Application) {
    let window = ApplicationWindow::builder()
        .application(app)
        .can_focus(false)
        .title("Grid Layout")
        .build();

    window.init_layer_shell();
    window.set_layer(Layer::Top);

    let (grid_width, grid_height) = widget_size(COLS, ROWS);

    let grid = Grid::builder()
        .row_spacing(CELL_GAP as u32)
        .column_spacing(CELL_GAP as u32)
        .row_homogeneous(true)
        .column_homogeneous(true)
        .width_request(grid_width)
        .height_request(grid_height)
        .halign(gtk::Align::Start)
        .valign(gtk::Align::Start)
        .build();

    // 每个单元格都保留一个控件;(0, 0) 留给按钮。
    for row in 0..ROWS {
        for col in 0..COLS {
            if (col, row) != (0, 0) {
                grid.attach(&empty_cell(), col, row, 1, 1);
            }
        }
    }

    let button = Button::builder()
        .label("drag")
        .width_request(CELL_SIZE)
        .height_request(CELL_SIZE)
        .build();

    grid.attach(&button, 0, 0, 1, 1);

    let drag = GestureDrag::new();

    drag.connect_drag_end(clone!(
        #[weak]
        button,
        #[weak]
        grid,
        move |_, offset_x, offset_y| {
            let (col, row, _, _) = grid.query_child(&button);
            let step = f64::from(CELL_SIZE + CELL_GAP);

            // 保留原代码的规则:拖满一个步长才移动一格。
            let dx = (offset_x / step) as i32;
            let dy = (offset_y / step) as i32;

            let new_col = (col + dx).clamp(0, COLS - 1);
            let new_row = (row + dy).clamp(0, ROWS - 1);

            if (new_col, new_row) == (col, row) {
                return;
            }

            let placeholder = grid
                .child_at(new_col, new_row)
                .expect("目标单元格应当存在占位控件");

            grid.remove(&placeholder);
            grid.remove(&button);

            // 恢复原位置的占位,再把按钮放到目标位置。
            grid.attach(&empty_cell(), col, row, 1, 1);
            grid.attach(&button, new_col, new_row, 1, 1);
        }
    ));

    button.add_controller(drag);
    window.set_child(Some(&grid));
    window.present();
}

通过 query_child() 直接获取按钮的当前位置,就不必另外维护一份 Cell<(i32, i32)>。每次移动后,所有单元格仍各有一个控件,空位的尺寸也能保留。

需要注意什么?

queue_draw() 无法解决空行尺寸问题

queue_draw() 请求 GTK 在当前帧或下一帧重新绘制控件。GtkWidget.queue_draw 文档

这里的问题来自布局规则,重新绘制不会让空行获得 80 的高度,也不需要反复调用 queue_resize()。正常增删子控件后,GTK 会安排相应的布局更新。

占位控件必须参与布局

占位控件可以没有可见内容,但要保持可见状态。如果通过 set_visible(false) 隐藏它们,它们就无法继续占位。

也不要在同一个单元格中同时挂接占位控件和按钮。GTK 文档说明,多个子控件占据同一网格单元格时,行为未定义,所以示例先移除目标位置的占位控件,再添加按钮。GtkGrid 文档

80 是尺寸请求,不是绝对尺寸

示例用 CELL_SIZE + CELL_GAP,即 96,换算拖动距离,前提是实际单元格大小符合这一设定。如果主题、字体或控件内容需要更大的尺寸,实际单元格也可能变大,此时就要根据实际布局计算拖动步长。

as i32 会向零截断,因此拖动不足一个步长时,按钮不会移动。如果希望吸附到最近的单元格,可以改为:

let dx = (offset_x / step).round() as i32;
let dy = (offset_y / step).round() as i32;

示例适用于题目中的单按钮场景。如果以后加入多个可移动控件,还需要在移动前检查目标单元格是否已被占用,并确定拒绝移动、交换位置等处理规则。

备注:内容仅供参考。