我们在使用各个平台的应用程序时, 可能会因为窗体自身带有一个外框 (包括图标, 窗体标题, 若干按键) 而影响界面的布局和实现. 因此, 当我们有了专门的设计图之后, 可以通过自定义窗体的方式来保证各个桌面平台的一致体验.

在这片博客中, 我们以 eframe 为例, 创建一个简单的自定义窗体. 以下是我们的实现目标:

  1. 消除默认边框
  2. 圆角边框
  3. 可拖拽移动窗体位置
  4. 可通过边缘缩放窗体

消除默认边框

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 实例进行操作. 因为窗体内部的所有内容的渲染全都需要我们自行绘制, 也就是说, 当 NativeOptionsdecorated 被置为 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
    }
}

随后, 由于 eguieframe 基于立即模式设计, 所以我们还需要通过内存变量的方式记录下缩放的状态, 从而允许用户进行连续的缩放操作:

#[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);
}

好了, 这样一来我们就实现了最简单的自定义窗体. 不仅实现了自定义样式, 还实现了标准窗体行为.

2

会影响到内部元素的布局, 因为它会影响内部的空间大小.

1

不会影响内部元素的布局, 但是它会影响绘制的内容位置.

3

注意触摸屏没有此位置.