Dec 21, 2025
4 min read
GUI,
Rust,

Rust GPUI 框架如何实现圆形进度条

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

就是这个东西:

demo1

但是当给这个圆形加载环添加进度条时,上面的方案有点行不通(除非你要准备 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, ())
        }
    }

最后的效果如下:

效果图