GPUI 顶层的 API 没有办法实现一个功能比较丰富的圆形进度条。当然一个简单的圆形加载环是可以非常简单地实现的,比如使用 svg 元素,然后添加 svg 旋转动画。
就是这个东西:

但是当给这个圆形加载环添加进度条时,上面的方案有点行不通(除非你要准备 100 个 svg 元素,然后根据进度条的进度,去设置 svg 元素的显示)。
最好的办法是,使用 canvas api。 而且,我们不能从Render 或者 RenderOnce 中实现这个组件,而是从更底层的 Element 中实现。
它们三者的关系大概是这样的:
graph TD
A[Element] --> B[RenderOnce]
B --> C[Render]
A1[基础元素特性] --> A
B1[单次渲染] --> B
C1[可变状态渲染] --> C
D[最底层] --> A
E[中间层] --> B
F[最高层] --> C
设计
我们要实现 2 种模式的支持,当不添加进度条数值时,默认就是 1/4 圆形加载环并播放旋转动画(就像上图动画);当添加进度条数值时,就显示进度条数值。
// 模式1:旋转动画(默认)
CircularProgress::new() // 显示旋转的90度圆弧
// 模式2:进度显示
CircularProgress::new().value(0.75) // 显示75%进度
这种设计允许同一个组件满足两种不同的使用场景,避免了创建多个相似组件的复杂性。
图形绘制
没有进度条时,旋转的是一个 1/4 圆环,两头圆角。我们可以使用 canvas 的路径绘制来实现。
fn draw_thick_quarter_arc(
x: Pixels,
y: Pixels,
radius: Pixels,
rotation_angle: f32,
) -> Path<Pixels> {
let center = point(x, y);
let mut builder = PathBuilder::stroke(px(4.0)).with_style(PathStyle::Stroke(
StrokeOptions::default()
.with_line_cap(lyon_path::LineCap::Round)
.with_line_width(4.0),
));
// 计算旋转后的起始点
let start_angle = rotation_angle.to_radians();
let start_x = center.x + radius * start_angle.cos();
let start_y = center.y + radius * start_angle.sin();
// 移动到旋转后的起始点
builder.move_to(point(start_x, start_y));
// 计算旋转后的结束点(90度弧)
let end_angle = (rotation_angle + 90.0).to_radians();
let end_x = center.x + radius * end_angle.cos();
let end_y = center.y + radius * end_angle.sin();
// 绘制90度弧段
builder.arc_to(
point(radius, radius),
px(0.0),
false, // 小弧
true, // 顺时针
point(end_x, end_y),
);
builder.build().unwrap() // 构建路径
}
核心的要点是 with_line_cap 实现两边的圆角,使用with_line_width 实现环的宽度,使用 arc_to 实现弧形。
当有进度条时,我们需要实现进度条值来控制环的弧度。因此需要把进度条 0-1 转换为圆的 0-360 度。
// 计算进度对应的角度(360度 * 进度值)
let progress_angle = 360.0 * progress.clamp(0.0, 1.0);
由于圆弧是由 arc_to 方法实现的,因此我们需要知道弧形的起始点、结束点,以及旋转的角度。一般我们以 12 点钟方向为 0 度,顺时针增加。
那么起始点为:
// 起始角度固定为 -90度(12点钟方向开始)
let start_angle: f32 = -90.0;
let start_x = center.x + radius * start_angle.to_radians().cos();
let start_y = center.y + radius * start_angle.to_radians().sin();
结束点为:
// 计算结束点
let end_angle = start_angle + progress_angle;
let end_x = center.x + radius * end_angle.to_radians().cos();
let end_y = center.y + radius * end_angle.to_radians().sin();
整体的代码如下:
// 添加新的绘制函数
fn draw_progress_arc(
x: Pixels,
y: Pixels,
radius: Pixels,
progress: f32, // 进度值,范围 0.0 到 1.0
) -> Path<Pixels> {
let center = point(x, y);
let mut builder = PathBuilder::stroke(px(4.0)).with_style(PathStyle::Stroke(
StrokeOptions::default()
.with_line_cap(lyon_path::LineCap::Round)
.with_line_width(4.0),
));
// 计算进度对应的角度(360度 * 进度值)
let progress_angle = 360.0 * progress.clamp(0.0, 1.0);
// 起始角度固定为 -90度(12点钟方向开始)
let start_angle: f32 = -90.0;
let start_x = center.x + radius * start_angle.to_radians().cos();
let start_y = center.y + radius * start_angle.to_radians().sin();
// 移动到起始点
builder.move_to(point(start_x, start_y));
// 计算结束点
let end_angle = start_angle + progress_angle;
let end_x = center.x + radius * end_angle.to_radians().cos();
let end_y = center.y + radius * end_angle.to_radians().sin();
// 绘制进度弧段
builder.arc_to(
point(radius, radius),
px(0.0),
progress_angle > 180.0, // 当角度大于180度时使用大弧
true, // 顺时针
point(end_x, end_y),
);
builder.build().unwrap()
}
主要使用了三角函数精确计算圆弧位置。
文本渲染
当显示具体进度时,组件会在圆心位置显示百分比文本:
// 在圆心显示百分比数值
if let Some(value) = self.value {
let percentage_text = format!("{:.0}%", value * 100.0);
// 精确的文本居中计算
let text_width = shaped_line.width;
let text_height = font_size * 1.2;
let text_start_x = text_center_x - text_width / 2.0;
let text_start_y = text_center_y - text_height / 2.0;
// 使用 GPUI 文本系统渲染
let _ = shaped_line.paint(text_origin, font_size, window, _cx);
}
动画系统
我们需要一个动画状态管理器,来管理动画的状态。
// 动画状态结构体
#[derive(Clone)]
struct AnimationState {
start: Instant,
rotation_angle: f32,
}
组件使用了 GPUI 的元素状态管理来实现平滑的动画:
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
_local_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
// 使用 gpui 的元素状态管理来确保动画持续运行
if let Some(global_id) = global_id {
window.with_element_state(global_id, |state, window| {
let mut state = state.unwrap_or_else(|| AnimationState {
start: Instant::now(),
rotation_angle: 0.0,
});
// 更新旋转角度
let now = Instant::now();
let delta = now.duration_since(state.start).as_secs_f32();
state.rotation_angle = (delta * 360.0/*每秒旋转360度*/) % 360.0;
// 更新组件的旋转角度
self.rotation_angle = state.rotation_angle;
let (circular_size, _stroke_width) = self.size.get_size();
// 如果有标签,需要额外的高度空间
let total_height = if self.label.is_some() {
circular_size + 25.0 // 标签高度
} else {
circular_size
};
// 创建样式
let style = Style {
size: Size::new(px(circular_size).into(), px(total_height).into()),
..Default::default()
};
// 返回布局ID和状态
let layout_id = window.request_layout(style, None, cx);
// 请求下一帧动画
window.request_animation_frame();
((layout_id, ()), state)
})
} else {
// 如果没有全局ID,使用简单的实现
let now = Instant::now();
if let Some(last_update) = self.last_update {
let delta = now.duration_since(last_update).as_secs_f32();
self.rotation_angle = (self.rotation_angle + delta * 180.0) % 360.0;
}
self.last_update = Some(now);
let (circular_size, _stroke_width) = self.size.get_size();
// 创建样式
let style = Style {
size: Size::new(px(circular_size).into(), px(circular_size).into()),
..Default::default()
};
let layout_id = window.request_layout(style, None, cx);
window.request_animation_frame();
(layout_id, ())
}
}
最后的效果如下:
