如何在 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。
具体步骤如下:
- 在按钮之外的所有单元格中放入占位控件。
- 拖动结束后,根据当前网格坐标计算目标坐标,并将其限制在网格范围内。
- 移除目标位置的占位控件,将按钮移过去,再补上原位置的占位控件。
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;
示例适用于题目中的单按钮场景。如果以后加入多个可移动控件,还需要在移动前检查目标单元格是否已被占用,并确定拒绝移动、交换位置等处理规则。
备注:内容仅供参考。
