Tạo Bộ Điều Khiển Tùy Chỉnh Dạng Đồng Hồ Đo Với GDI+ trong C#

Thiết kế một điều khiển đồ họa dạng đồng hồ đo (dial gauge) là bài toán phổ biến trong các ứng dụng giám sát công nghiệp hoặc giao diện người dùng nhúng. Giải pháp dưới đây sử dụng System.Drawing để vẽ trực tiếp lên bề mặt điều khiển — không phụ thuộc vào thư viện bên ngoài, đảm bảo tính nhẹ và khả năng tích hợp cao.

Nguyên lý thiết kế cốt lõi

  • Xác định tâm vẽ: Dựa trên kích thước nhỏ hơn giữa chiều rộng và chiều cao để đảm bảo đồng hồ luôn nằm gọn trong vùng hiển thị, bất kể tỷ lệ khung hình.
  • Phân vùng vòng ngoài: Vòng đo được chia thành hai đoạn — phần "bình thường" (màu trái) và phần "cảnh báo" (màu phải), điều khiển bằng góc phân giới LeftAngle.
  • Đánh dấu vạch chia: Sử dụng các hình chữ nhật mỏng được xoay quanh tâm để mô phỏng vạch chia. Màu sắc vạch được xác định động dựa trên vị trí tương đối với ngưỡng cảnh báo.
  • Hiển thị giá trị số: Các nhãn số được căn chỉnh theo ba kiểu: Near (trái), Center (giữa), Far (phải) nhằm đạt độ cân xứng thị giác.
  • Vẽ kim chỉ thị: Không vẽ kim dạng bitmap — thay vào đó, tính góc quét dựa trên tỷ lệ giá trị hiện tại so với phạm vi đo, rồi vẽ đoạn thẳng từ tâm ra biên trong.

Thuộc tính cấu hình linh hoạt

Mỗi thuộc tính đều được gắn thẻ [Browsable], [Category] và [Description] để xuất hiện rõ ràng trong Visual Studio Designer. Việc thay đổi bất kỳ thuộc tính nào đều gọi Invalidate(), kích hoạt lại phương thức OnPaint.

Đoạn mã triển khai thuộc tính (đã tối ưu hóa)

private Color normalZoneColor = Color.FromArgb(76, 145, 82);
[Browsable(true)]
[Category("Giao diện")]
[Description("Màu vùng hoạt động bình thường")]
public Color NormalZoneColor
{
    get => normalZoneColor;
    set
    {
        normalZoneColor = value;
        Invalidate();
    }
}

private Color alertZoneColor = Color.FromArgb(204, 67, 67);
[Browsable(true)]
[Category("Giao diện")]
[Description("Màu vùng cảnh báo")]
public Color AlertZoneColor
{
    get => alertZoneColor;
    set
    {
        alertZoneColor = value;
        Invalidate();
    }
}

private float maxValue = 200f;
[Browsable(true)]
[Category("Hành vi")]
[Description("Giá trị cực đại của dải đo")]
public float MaxReading
{
    get => maxValue;
    set
    {
        if (value <= 0) return;
        maxValue = value;
        Invalidate();
    }
}

private float currentReading = 132.5f;
[Browsable(true)]
[Category("Hành vi")]
[Description("Giá trị hiện tại được hiển thị")]
public float Reading
{
    get => currentReading;
    set
    {
        if (value < 0 || value > maxValue) return;
        currentReading = value;
        Invalidate();
    }
}

private string labelPrefix = "Áp suất:";
[Browsable(true)]
[Category("Nhãn")]
[Description("Tiền tố hiển thị trước giá trị")]
public string LabelPrefix
{
    get => labelPrefix;
    set
    {
        labelPrefix = value ?? string.Empty;
        Invalidate();
    }
}

Hàm vẽ chính — tối ưu hóa luồng xử lý

Phương thức OnPaint được viết lại hoàn toàn nhằm tăng tính rõ ràng và giảm phụ thuộc vào trạng thái đồ họa toàn cục. Các phép biến đổi như RotateTransform và TranslateTransform được áp dụng có kiểm soát, kèm theo việc khôi phục trạng thái sau mỗi khối vẽ để tránh lỗi chồng lấn.

Đoạn mã vẽ đã cải tiến

protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e);
    using var g = e.Graphics;
    
    g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
    g.SmoothingMode = SmoothingMode.HighQuality;

    var clientRect = ClientRectangle;
    if (clientRect.Width < 30 || clientRect.Height < 30) return;

    // Xác định bán kính hiệu dụng và tâm
    var radius = Math.Min(clientRect.Width, clientRect.Height) / 2;
    var centerX = clientRect.Width / 2f;
    var centerY = clientRect.Height / 2f;

    // Vẽ vòng ngoài: hai cung riêng biệt
    using var normalPen = new Pen(NormalZoneColor, OuterRingThickness);
    using var alertPen = new Pen(AlertZoneColor, OuterRingThickness);

    var arcRect = new RectangleF(
        centerX - radius + 5,
        centerY - radius + 5,
        (radius - 5) * 2,
        (radius - 5) * 2
    );

    g.DrawArc(normalPen, arcRect, -210, LeftAngle);
    g.DrawArc(alertPen, arcRect, -210 + LeftAngle, 210 - LeftAngle);

    // Dịch chuyển hệ tọa độ về tâm để dễ vẽ
    g.TranslateTransform(centerX, centerY);

    // Vẽ vạch chia (7 vạch, cách đều 30°)
    for (int i = 0; i < 7; i++)
    {
        var angleRad = (-Math.PI / 2) + (i * Math.PI / 6);
        var isNormal = (i * 30) <= LeftAngle;
        using var brush = new SolidBrush(isNormal ? NormalZoneColor : AlertZoneColor);

        var xStart = (float)(Math.Cos(angleRad) * (radius * 0.75));
        var yStart = (float)(Math.Sin(angleRad) * (radius * 0.75));
        g.FillRectangle(brush, xStart - 1.5f, yStart - 8f, 3f, 12f);
    }

    // Vẽ nhãn số
    for (int i = 0; i < 7; i++)
    {
        var angleRad = (-Math.PI / 2) + (i * Math.PI / 6);
        var xLabel = (float)(Math.Cos(angleRad) * (radius * 0.85));
        var yLabel = (float)(Math.Sin(angleRad) * (radius * 0.85));

        var labelValue = (MaxReading / 6f * i).ToString(i == 3 ? "F1" : "F0");
        var format = new StringFormat { Alignment = i switch { 0 or 1 or 2 => StringAlignment.Near, 3 => StringAlignment.Center, _ => StringAlignment.Far } };
        
        g.DrawString(labelValue, ScaleFont, new SolidBrush(TextColor), 
            new RectangleF(xLabel - 25, yLabel - 8, 50, 16), format);
    }

    // Vẽ cung chỉ thị nội vi
    using var indicatorPen = new Pen(NormalZoneColor, InnerRingThickness);
    var sweepAngle = (currentReading / maxValue) * 210f;
    var innerRadius = radius * InnerScale;
    var innerRect = new RectangleF(-innerRadius, -innerRadius, innerRadius * 2, innerRadius * 2);
    g.DrawArc(indicatorPen, innerRect, -210, sweepAngle);

    // Vẽ kim chỉ thị dạng đoạn thẳng
    var needleAngle = (-210 + sweepAngle) * Math.PI / 180;
    var needleEndX = (float)(Math.Cos(needleAngle) * (innerRadius + InnerRingThickness * 0.5f));
    var needleEndY = (float)(Math.Sin(needleAngle) * (innerRadius + InnerRingThickness * 0.5f));
    
    using var needlePen = new Pen(NormalZoneColor, 2.5f);
    g.DrawLine(needlePen, 0, 0, needleEndX, needleEndY);

    // Hiển thị giá trị trung tâm
    if (ShowReadingText)
    {
        var textRect = new RectangleF(-centerX, centerY * TextPositionRatio - 12, clientRect.Width, 24);
        var textValue = $"{LabelPrefix} {currentReading:F1} {Unit}";
        g.DrawString(textValue, ValueFont, new SolidBrush(ValueTextColor), textRect, 
            new StringFormat { Alignment = StringAlignment.Center });
    }
}

Chú ý triển khai thực tế

  • Luôn sử dụng using cho các tài nguyên đồ họa (Pen, SolidBrush) để đảm bảo giải phóng bộ nhớ.
  • Tránh tạo mới Font trong OnPaint — nên khởi tạo ở constructor và lưu làm trường lớp.
  • Kích thước vùng vẽ cần kiểm tra trước khi bắt đầu — đặc biệt khi điều khiển bị thu nhỏ đến mức không hợp lý.
  • Các hằng số góc (ví dụ: -210, 210) nên được định nghĩa như const để tăng khả năng bảo trì.

Thẻ: csharp GDI+ WinForms custom-control drawing

Đăng vào ngày 14 tháng 10 lúc 13:14