我们在使用各个平台的应用程序时, 可能会因为窗体自身带有一个外框 (包括图标, 窗体标题, 若干按键) 而影响界面的布局和实现. 因此, 当我们有了专门的设计图之后, 可以通过自定义窗体的方式来保证各个桌面平台的一致体验.
在这片博客中, 我们以 eframe
为例, 创建一个简单的自定义窗体. 以下是我们的实现目标:
- 消除默认边框
- 圆角边框
- 可拖拽移动窗体位置
- 可通过边缘缩放窗体
消除默认边框
eframe
是通过 winit
实现跨平台的原生支持的, 因此可以使用它的 WindowBuilder
来配置窗体的外部样式. 但我们使用了 eframe
, 无法直接创建, 因此可以通过 eframe::NativeOptions
来间接调整:
let mut options = eframe::NativeOptions::default();
options.decorated = false;
options.transparent = true;
options.default_theme = eframe::Theme::Light;
其中, decorated
表示关闭窗体的外部边框. 将其关闭后, 包括标题栏, 边框都会消失. 同时这样在 Windows 等平台的副作用是失去了直接在窗体拖拽及缩放的可能性. 因此我们需要在后面手动实现这些功能.
transparent
则是用于将窗体本身的颜色设置为透明. 但是这样还不够, 我们还需要在实现 eframe::App
trait 的同时, 通过重载 clear_color()
方法来指引 OpenGL (等) 使用透明色清除窗体的内容. 即:
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
egui::Color32::TRANSPARENT.to_normalized_gamma_f32()
}
这里需要注意, 返回值的 [f32; 4]
中各个 component 的取值都必须介于 0.0 - 1.0
, 因此我们必须将 RGB 颜色映射到这个空间中. 所以这里采用了 to_normalized_gamma_f32()
方法.
经过这一步, 我们可以实现下面的效果:
圆角边框
在 eframe
中, 每个应用程序进程只能有一个 UI 主线程 (来自上游 winit
的限制), 这个线程每次渲染窗体时会生成一个 eframe::Frame
实例. 这时我们操作应用程序的句柄, 可以控制窗体的各种行为. 但我们需要改变窗体的性状时, 却无法直接对 eframe::Frame
实例进行操作. 因为窗体内部的所有内容的渲染全都需要我们自行绘制, 也就是说, 当 NativeOptions
的 decorated
被置为 false
之后, 我们得到的就是一块空白的屏幕区域.
这种策略也有失: 我们对窗体的内容获得了完全的控制权, 同时也丢失了兼容操作系统窗体阴影的能力.
说来也简单, egui
中定义屏幕绘制空间的最核心的几个 *Panel
类型都提供了 frame()
方法用于由开发者设置整个区域的视觉特征. 注意, 这里要给出的时 egui::Frame
类型, 而不是 eframe::Frame
类型, 两者是完全不同的概念.
在 egui::Frame
类型中, 我们可以定义圆角 (rounding
), 底色 (fill
), 边框 (stroke
), 外边距 (outer_margin
)1, 内边距 (inner_margin
)2. 下图是设置圆角和边框之后的效果:
let title_bar_frame = egui::Frame {
rounding: egui::Rounding {
sw: 0.0,
ne: 8.0,
nw: 8.0,
se: 0.0,
},
fill: egui::Color32::TRANSPARENT,
stroke: egui::Stroke::new(1.0, egui::Color32::TRANSPARENT),
inner_margin: egui::Margin::symmetric(16.0, 0.0),
..Default::default()
};
可拖拽移动窗体位置
首先回忆起刚才我们提到了任何窗体的行为 (而不是形状) 都可以通过 eframe::Frame
的实例进行控制. 比如这里要谈到的窗体位置就是通过 drag_window()
方法来实现的:
let layout_rect = ui.max_rect();
let response = ui.interact(
layout_rect,
egui::Id::new("action_bar_interation"),
egui::Sense::click_and_drag(),
);
if response.dragged() {
if response.is_pointer_button_down_on() {
if !frame.is_web() {
frame.drag_window();
}
}
};
首先, 我们在一个顶栏区域, 或是任何需要支持拖拽的范围内生成一个覆盖整个区域的 egui::Rect
对象, 即 layout_rect
. 随后, 使用 ui.interact()
方法获得一个可以捕获用户鼠标事件的 egui::Response
. 接着依次判断:
- 指针是否在拖拽过程中?
- 鼠标 (主) 按键是否正处于按下状态?
经过上述判断, 我们已经可以知道用户的意图是拖拽整个窗体了, 但在调用 drag_window()
之前, 还需要排除用户正在使用浏览器 (WASM) 的情况, 因此需要通过 is_web()
判断.
BONUS: 如果我们想要实现双击自定义的顶栏最大化/恢复窗口怎么办? 只需要用相似的逻辑添加下面的内容:
if response.double_clicked() {
if !frame.is_web() {
let info = frame.info().window_info;
frame.set_maximized(!info.maximized);
ui.ctx().request_repaint();
}
}
唯一要注意的就是对窗体信息的查询. 包括窗体位置, 窗体大小等等信息都包含在 egui::Frame::info().window_info
中, 只要能访问到 eframe::Frame
实例, 就可以实现对这些信息的查询和更改. 比如调用 eframe::Frame::close()
可以直接关闭窗体并退出程序.
可通过边缘缩放窗体
在刚才通过 eframe::Frame
查询窗体信息的基础上, 我们还可以通过 egui::Context::input()
方法来获得当前的用户交互信息. 其中包括鼠标指针, 键盘等多项内容. 我们依次循序渐进操作.
首先, 我们要定义缩放的方向: 水平, 垂直, 同时水平和垂直. 为了表示应用程序的一般状态, 我们还需要加入一个 "静态" 的定义, 即:
#[derive(PartialEq, Eq)]
pub enum ResizeDirection {
Vertical,
Horizontal,
Both,
None,
}
impl Default for ResizeDirection {
fn default() -> Self {
Self::None
}
}
随后, 由于 egui
和 eframe
基于立即模式设计, 所以我们还需要通过内存变量的方式记录下缩放的状态, 从而允许用户进行连续的缩放操作:
#[derive(Default)]
pub struct MyApp {
pointer_primary_down: bool,
pointer_resize_direction: ResizeDirection,
}
接下来首先要判断当前的锁房状态, 并设置鼠标指针的图标. 我们的逻辑是判断指针当前位置 (ctx.pointer_latest_pos()
3) 和窗体边缘的关系. 当离窗体的举例小于或等于 2 个逻辑像素时就可以判断为准备在此位置执行缩放拖拽操作了. 这里注意需要 分别判断垂直方向和水平方向, 从而判断出鼠标指针位于四个角落的情况, 代码片段如下:
if let Some(pos) = ctx.pointer_latest_pos() {
if !frame.info().window_info.maximized
&& self.pointer_resize_direction == ResizeDirection::None
{
let window_size = frame.info().window_info.size;
let (mut handle_west, mut handle_north, mut handle_east, mut handle_south) =
(false, false, false, false);
if pos.x <= 2.0 {
handle_west = true;
} else if (window_size.x - pos.x) <= 2.0 {
handle_east = true;
}
if pos.y <= 2.0 {
handle_north = true;
} else if (window_size.y - pos.y) <= 2.0 {
handle_south = true;
}
if handle_north || handle_east || handle_south || handle_west {
use egui::CursorIcon as Icon;
let icon = match (handle_north, handle_east, handle_south, handle_west) {
(true, true, false, false) | (false, false, true, true) => {
self.pointer_resize_direction = ResizeDirection::Both;
egui::CursorIcon::ResizeNeSw
}
(true, false, false, true) | (false, true, true, false) => {
self.pointer_resize_direction = ResizeDirection::Both;
egui::CursorIcon::ResizeNwSe
}
(true, false, false, false) => {
self.pointer_resize_direction = ResizeDirection::Vertical;
Icon::ResizeNorth
}
(false, true, false, false) => {
self.pointer_resize_direction = ResizeDirection::Horizontal;
Icon::ResizeEast
}
(false, false, true, false) => {
self.pointer_resize_direction = ResizeDirection::Vertical;
Icon::ResizeSouth
}
(false, false, false, true) => {
self.pointer_resize_direction = ResizeDirection::Horizontal;
Icon::ResizeWest
}
_ => panic!("Impossible situation"),
};
ctx.set_cursor_icon(icon);
}
}
}
然后我们可以获取鼠标指针在主键按下时拖拽的距离, 从而推算缩放的程度:
let mut resize_delta: egui::Vec2 = egui::vec2(0.0, 0.0);
ctx.input(|input_state| {
if !input_state.pointer.primary_down() {
self.pointer_primary_down = false;
self.pointer_resize_direction = ResizeDirection::None;
} else {
resize_delta = input_state.pointer.delta();
self.pointer_primary_down = true;
}
});
最后一步, 根据前面获得的缩放方向, 执行缩放操作. 我们根据垂直, 水平, 同时水平垂直三种情况执行:
if self.pointer_primary_down && self.pointer_resize_direction != ResizeDirection::None {
let mut window_size = frame.info().window_info.size;
let screen_size = frame
.info()
.window_info
.monitor_size
.unwrap_or(egui::vec2(1024.0, 768.0));
match self.pointer_resize_direction {
ResizeDirection::Both => {
window_size.x += resize_delta.x;
window_size.y += resize_delta.y;
}
ResizeDirection::Vertical => {
window_size.y += resize_delta.y;
}
ResizeDirection::Horizontal => {
window_size.x += resize_delta.x;
}
_ => (),
};
const MIN_SIZE: egui::Vec2 = egui::vec2(640.0, 480.0);
window_size = window_size.clamp(MIN_SIZE, screen_size);
frame.set_window_size(window_size);
}
好了, 这样一来我们就实现了最简单的自定义窗体. 不仅实现了自定义样式, 还实现了标准窗体行为.
会影响到内部元素的布局, 因为它会影响内部的空间大小.
不会影响内部元素的布局, 但是它会影响绘制的内容位置.
注意触摸屏没有此位置.