[WPF] 製作進度環 (2) 極座標與 StrokeThickness

  • 1026
  • 0

前一篇簡單的利用了既有的固定座標繪製弧形,以進度環的觀點來看,基本上應該是以夾角的角度來計算才對,這會應用到數學上的直角座標與極座標轉換。

座標公式

假設已知圓心的點座標為 (x0, y0)、半徑為 r 、夾角為 θ。則在此圓上的任一點 (x1,y1) 的公式為以下:

x1 = x0 + r * cos ( θ * π / 180 )
y1 = y0 + r * sin ( θ * π / 180 )

極座標系的角度是從 3 點鐘方向 ( 0 度) 逆時鐘增加,可參考下圖

圖1:極座標系,引用自 wiki 極座標系
圖2:直角座標系,引用自 wiki 極座標系
設定角度換算螢幕座標

依據上述的公式,我們要先決定中心點的位置,如果在一個寬高為 (width, height) ,正方形區域內繪製一個半徑為 ( width/2, height/2 ) 的弧形,圓心的座標為 ( width/2, height/2 ) 。

12 點鐘方向(夾角 90度) 換算為直角座標系的起點應為:

x= (width/2) + (width/2) * cos ( 90 * π / 180) ,其中 cos ( π / 2) = 0,故 x = (width/2)
y= (height/2) + (height/2) * sin ( 90 * π / 180) ,其中 sin ( π / 2) = 1,故 y = height

這邊有一個問題,這裡換算出來的直角座標系是上面圖二的座標系,這種座標系是由左下方往右上方增加,但螢幕的座標系是從左上角往右下方增加的;所以 y 座標要做一些修正。以這個例子直角座標系的 y = 0 是螢幕座標系的 y = height,所以再度換算成螢幕座標系:

y= height - ((height/2) + (height/2) * sin ( 90 * π / 180))  = 0。

得出在螢幕上的 12 點鐘方向起點為  ( width/2 , 0 )

3 點鐘方向換算為螢幕座標系為:

x = (width/2) + (width/2) * cos ( 0 * π / 180) = (width/2) + (width/2) * 1 = width
y = height - ((height/2) + (height/2) * sin ( 0 * π / 180)) =  height - ((height/2) + (height/2) * 0) = height - (height/2) = height/2 

角度換算

因為起點是極座標的 90 度,要順時鐘繞圈,假設要移動角度為 α ( 0 < α ≤ 360 ),若  0 < α ≤ 90 ,則極座標的 θ = 90 - α;若在前述範圍外則 θ = (90 - α)+ 360。也就是說如果終點的位置是對應起點 180 度,換算成極座標的角度就是 90 - 180 + 360 = 270 度,說白了就是 90 - α 只要 < 0 就加上 360。

完成程式碼

(1) 為終點角度設定一個依賴屬性

public static readonly DependencyProperty EndAngleProperty =
    DependencyProperty.Register(nameof(EndAngle), typeof(double), typeof(Arc), new PropertyMetadata(0.0, null, new CoerceValueCallback(OnEndAngleChanged)));

private static object OnEndAngleChanged(DependencyObject d, object baseValue)
{
    var value = (double)baseValue;
    if (value < 0 ) { value = 0; }
    if (value > 359.9) { value = 359.9; }
    return value;
}

public double EndAngle
{
    get => (double)GetValue(EndAngleProperty);
    set => SetValue(EndAngleProperty, value);
}

  這段程式碼比較特別的地方是用到了 CoerceValueCallback,當依賴屬性的值被設定的時候會呼叫這個委派,它將外部設定的值透過 baseValue 參數傳遞給委派函式,而這個函式的回傳值將會重新設定依賴屬性的值。由於我們將角度限定在 0 ~ 360 度,所以在設定值的時候會做這個檢查,但如果終點設定為 360 度,這個圓的方向會長到不知那兒去,所以將極限設定為 359.9。

(2) 決定是否為 Large Arc

private bool IsLargeArc()
{
    return EndAngle > 180;
}

如果角度超過 180 ,則應該是 Large Arc。

(3) 取得螢幕座標

 private Point GetCenter()
 {
     return new Point(RenderSize.Width / 2, RenderSize.Height / 2);
 }

 private Point GetScreenCoordinate()
 {
     var angle = EndAngleToPolarAngle();
     var center = GetCenter();
     double radian = angle * Math.PI / 180.0;
     var x = center.X + (RenderSize.Width / 2) * Math.Cos(radian);
     var y = RenderSize.Height - (center.Y + (RenderSize.Height / 2) * Math.Sin(radian));
     return new Point(x, y);
 }

不論從直角坐標或螢幕座標的角度,圓心的座標都會一樣;但是圓上的點就不同了。

(4) 完整 Arc class 程式碼

public class Arc : Shape
{
    public static readonly DependencyProperty EndAngleProperty =
        DependencyProperty.Register(nameof(EndAngle), typeof(double), typeof(Arc), new PropertyMetadata(0.0, null, new CoerceValueCallback(OnEndAngleChanged)));

    private static object OnEndAngleChanged(DependencyObject d, object baseValue)
    {
        var value = (double)baseValue;
        if (value < 0) { value = 0; }
        if (value > 359.9) { value = 359.9; }
        return value;
    }


    public double EndAngle
    {
        get => (double)GetValue(EndAngleProperty);
        set => SetValue(EndAngleProperty, value);
    }

    protected override Geometry DefiningGeometry
    {
        get
        {
            StreamGeometry geometry = new StreamGeometry();
            using (StreamGeometryContext context = geometry.Open())
            {
                context.BeginFigure(new Point(RenderSize.Width / 2, 0), false, false);
                var end = GetScreenCoordinate();
                context.ArcTo(end, new Size(RenderSize.Width / 2, RenderSize.Height / 2), 0.0, IsLargeArc(), SweepDirection.Clockwise, true,true);
            }

            return geometry;
        }
    }

    private bool IsLargeArc()
    {
        return EndAngle > 180;
    }

    private double EndAngleToPolarAngle()
    {
        var result = 90.0 - EndAngle;
        if (result < 0) { result += 360.0; }
        return result;
    }

    private Point GetCenter()
    {
        return new Point(RenderSize.Width / 2, RenderSize.Height / 2);
    }

    private Point GetScreenCoordinate()
    {
        var angle = EndAngleToPolarAngle();
        var center = GetCenter();
        double radian = angle * Math.PI / 180.0;
        var x = center.X + (RenderSize.Width / 2) * Math.Cos(radian);
        var y = RenderSize.Height - (center.Y + (RenderSize.Height / 2) * Math.Sin(radian));
        return new Point(x, y);
    }
}

DefiningGeometry 透過組合上述的幾個方法完成弧形的繪製,來個畫面試試看。

<Border Width="100" Height="100" Background="LightBlue">
    <local:Arc Stroke="Brown" StrokeThickness="1" EndAngle="270" />
</Border>
圖3:使用角度設定弧形

這個階段的範例程式在此,結束了嗎?還沒,如果你將 xaml 中的 StrokeThickness 放大,你會發現弧形很明顯的會突出到 Border 外面,下一步要修正這個影響。

修正 StrokeThickness

當設定 StrokThickness 的時候,線條的粗細是由中心點往兩側擴散,所以要調整一下整個計算公式加入邊框修正:

public class Arc : Shape
{
    public static readonly DependencyProperty EndAngleProperty =
        DependencyProperty.Register(nameof(EndAngle), typeof(double), typeof(Arc), new PropertyMetadata(0.0, null, new CoerceValueCallback(OnEndAngleChanged)));

    private static object OnEndAngleChanged(DependencyObject d, object baseValue)
    {
        var value = (double)baseValue;
        if (value < 0) { value = 0; }
        if (value > 359.9) { value = 359.9; }
        return value;
    }

    public double EndAngle
    {
        get => (double)GetValue(EndAngleProperty);
        set => SetValue(EndAngleProperty, value);
    }

    protected override Geometry DefiningGeometry
    {
        get
        {
            StreamGeometry geometry = new StreamGeometry();
            using (StreamGeometryContext context = geometry.Open())
            {

                context.BeginFigure(new Point(RenderSize.Width / 2.0, StrokeThickness / 2.0), false, false);
                var end = GetScreenCoordinate();
                (double radiusX, double radiusY) = GetRadii();
                context.ArcTo(end, new Size(radiusX > 0 ? radiusX : 0, radiusY > 0 ? radiusY : 0), 0.0, IsLargeArc(), SweepDirection.Clockwise, true, true);
            }

            return geometry;
        }
    }

    private bool IsLargeArc()
    {
        return EndAngle > 180;
    }

    private double EndAngleToPolarAngle()
    {
        var result = 90.0 - EndAngle;
        if (result < 0) { result += 360.0; }
        return result;
    }

    private (double radiusX, double radiusY) GetRadii()
    {
        return ((RenderSize.Width - StrokeThickness) / 2.0 , (RenderSize.Height - StrokeThickness) / 2.0) ;
    }

    private Point GetScreenCoordinate()
    {
        var angle = EndAngleToPolarAngle();

        double radian = angle * Math.PI / 180.0;
        (double radiusX, double radiusY) = GetRadii();
        var x = RenderSize.Width / 2.0 + radiusX * Math.Cos(radian);
        var y = (RenderSize.Height - (StrokeThickness / 2.0)) - (radiusY + radiusY * Math.Sin(radian));
        return new Point(x, y);
    }
}
<Border Width="100" Height="100" Background="LightBlue">
    <local:Arc Stroke="Brown" StrokeThickness="10" EndAngle="270" />
</Border>
圖4:加入邊框調整

加入邊框調整後的範例程式碼在此

註:關於極座標的詳細內容可以參考 wiki 極座標系