Trong các ứng dụng đồ họa hoặc trình soạn thảo kéo thả, công cụ thước đo (Ruler) đóng vai trò quan trọng trong việc định vị và căn chỉnh đối tượng. Bài viết này hướng dẫn cách xây dựng một Ruler Control tùy chỉnh trong WPF, hỗ trợ cả trục ngang và dọc, cùng tính năng theo dõi vị trí chuột thời gian thực.
1. Kiến trúc cốt lõi của Ruler Control
Control này kế thừa từ Control và ghi đè phương thức OnRender để vẽ các vạch chia (ticks) và nhãn số dựa trên đơn vị đo (Pixel hoặc Centimet). Việc sử dụng TemplatePart giúp chúng ta truy cập và điều khiển các đường tracker (đường dóng) trong XAML template.
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Input;
namespace CustomRulerLibrary
{
[TemplatePart(Name = "PART_VerticalGuide", Type = typeof(Line))]
[TemplatePart(Name = "PART_HorizontalGuide", Type = typeof(Line))]
public class AdvancedRuler : Control
{
public static readonly DependencyProperty ScaleUnitProperty =
DependencyProperty.Register("ScaleUnit", typeof(UnitType), typeof(AdvancedRuler), new PropertyMetadata(UnitType.Pixel));
public static readonly DependencyProperty OrientationProperty =
DependencyProperty.Register("Orientation", typeof(Orientation), typeof(AdvancedRuler), new PropertyMetadata(Orientation.Horizontal));
public static readonly DependencyProperty ZoomLevelProperty =
DependencyProperty.Register("ZoomLevel", typeof(double), typeof(AdvancedRuler), new PropertyMetadata(1.0, OnVisualChanged));
private Line _vGuide;
private Line _hGuide;
static AdvancedRuler()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(AdvancedRuler), new FrameworkPropertyMetadata(typeof(AdvancedRuler)));
}
private static void OnVisualChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is AdvancedRuler ruler) ruler.InvalidateVisual();
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_vGuide = GetTemplateChild("PART_VerticalGuide") as Line;
_hGuide = GetTemplateChild("PART_HorizontalGuide") as Line;
}
public void UpdateCursorPosition(MouseEventArgs e)
{
Point pos = e.GetPosition(this);
if (Orientation == Orientation.Horizontal && _hGuide != null)
{
_hGuide.X1 = _hGuide.X2 = pos.X;
_hGuide.Y1 = 0;
_hGuide.Y2 = ActualHeight * 5; // Độ dài dóng xuống vùng làm việc
_hGuide.Visibility = Visibility.Visible;
}
else if (Orientation == Orientation.Vertical && _vGuide != null)
{
_vGuide.Y1 = _vGuide.Y2 = pos.Y;
_vGuide.X1 = 0;
_vGuide.X2 = ActualWidth * 5;
_vGuide.Visibility = Visibility.Visible;
}
}
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
Pen mainPen = new Pen(Foreground, 1.0);
double length = (Orientation == Orientation.Horizontal) ? ActualWidth : ActualHeight;
double interval = 100 * ZoomLevel;
int subDivisions = 10;
for (double i = 0; i <= length; i += interval / subDivisions)
{
bool isMajor = (i % interval == 0);
double tickSize = isMajor ? ActualHeight : ActualHeight * 0.4;
if (Orientation == Orientation.Horizontal)
{
dc.DrawLine(mainPen, new Point(i, 0), new Point(i, tickSize));
if (isMajor)
{
var text = CreateFormattedText((i / ZoomLevel).ToString("F0"));
dc.DrawText(text, new Point(i + 2, tickSize / 2));
}
}
else
{
dc.DrawLine(mainPen, new Point(0, i), new Point(tickSize, i));
if (isMajor)
{
var text = CreateFormattedText((i / ZoomLevel).ToString("F0"));
dc.PushTransform(new RotateTransform(-90, 0, i));
dc.DrawText(text, new Point(2, i + 2));
dc.Pop();
}
}
}
}
private FormattedText CreateFormattedText(string text)
{
return new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 9, Foreground, VisualTreeHelper.GetDpi(this).PixelsPerDip);
}
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
}
public enum UnitType { Pixel, Centimeter }
}
2. Định nghĩa Style và ControlTemplate
Phần XAML Style định nghĩa ngoại hình của thước và các đường chỉ dẫn (tracking lines). Các đường này thường có kiểu nét đứt để không làm rối giao diện người dùng.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomRulerLibrary">
<Style TargetType="{x:Type local:AdvancedRuler}">
<Setter Property="Background" Value="White"/>
<Setter Property="Foreground" Value="#444"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:AdvancedRuler}">
<Grid Background="{TemplateBinding Background}">
<!-- Đường dóng dọc cho thước ngang -->
<Line x:Name="PART_HorizontalGuide"
Stroke="Red"
StrokeThickness="1"
StrokeDashArray="4,2"
Visibility="Collapsed"/>
<!-- Đường dóng ngang cho thước dọc -->
<Line x:Name="PART_VerticalGuide"
Stroke="Red"
StrokeThickness="1"
StrokeDashArray="4,2"
Visibility="Collapsed"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
3. Tích hợp vào Editor Container
Để tạo ra một khu vực làm việc hoàn chỉnh, chúng ta bọc các thước đo vào một UserControl. Layout này sử dụng Grid 2x2: ô góc (0,0) thường để trống, ô (0,1) là thước ngang, ô (1,0) là thước dọc, và ô (1,1) là vùng nội dung chính.
<UserControl x:Class="CustomRulerLibrary.RulerContainer"
xmlns:local="clr-namespace:CustomRulerLibrary">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="25"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="25"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Thước ngang -->
<local:AdvancedRuler x:Name="TopRuler" Orientation="Horizontal" Grid.Column="1"/>
<!-- Thước dọc -->
<local:AdvancedRuler x:Name="LeftRuler" Orientation="Vertical" Grid.Row="1"/>
<!-- Vùng làm việc -->
<Canvas Grid.Row="1" Grid.Column="1" Background="Transparent" x:Name="WorkArea"/>
</Grid>
</UserControl>
4. Điều khiển logic sự kiện
Trong code-behind của cửa sổ chính hoặc container, chúng ta cần chuyển tiếp sự kiện di chuyển chuột đến các instance của Ruler để cập nhật vị trí đường dóng.
public partial class EditorWindow : Window
{
public EditorWindow()
{
InitializeComponent();
this.MouseMove += OnGlobalMouseMove;
}
private void OnGlobalMouseMove(object sender, MouseEventArgs e)
{
// Cập nhật vị trí cho cả hai thước đo
TopRuler.UpdateCursorPosition(e);
LeftRuler.UpdateCursorPosition(e);
}
}
Việc tính toán tọa độ trong OnRender cần lưu ý đến DPI của màn hình để đảm bảo độ chính xác khi hiển thị đơn vị vật lý như Centimet. Sử dụng VisualTreeHelper.GetDpi là phương pháp hiện đại và chính xác nhất trong các phiên bản .NET Framework mới hoặc .NET Core/5+.