WPF开发中遇到的新知识 -- 11
前言
通过可视化的形式,规划AGV行驶中遇到的交通管制区域,能够避免区域划分的错漏。首先是通过描点呈现出地图,然后每个区域使用矩形来包含这些点。
这次依旧是使用MVVM的模式开发,使用到的库有 CommunityToolkit.Mvvm
和 Microsoft.Xaml.Behaviors.Wpf
一、地图
首先是加载地图,地图本身就具有x,y坐标值,所以可以直接使用 Canvas
来做布局,不过要注意的是,在 Canvas
的外面需要包装一层 Border,也就是需要一个父容器,因为后期我们需要的缩放功能是以父容器坐标系做定位,具体原因暂时没弄懂。
<Border
x:Name="outside"
Background="#1a1a1a"
ClipToBounds="True">
<i:Interaction.Behaviors>
<local:CanvasBehaviorOnMouse ModifierKey="LeftCtrl" UseInChildren="{Binding ElementName=c1}"/>
</i:Interaction.Behaviors>
<Canvas x:Name="c1"
Background="#1a1a1a"
Focusable="False">
<i:Interaction.Behaviors>
<local:CanvasBehavior />
</i:Interaction.Behaviors>
</Canvas>
</Border>
先总体看一下,就是一个 Border
包含一个 Canvas
,然后是需要将 Border
的ClipToBounds 设置,不然待会儿会有点超出界面。然后还有一些代码下面讲
然后是数据的加载,设置了一个按钮来选择文件,使用的是 openFileDiaglog
<Button Content="选择地图文件" Command="{Binding SelectFileCommand}"/>
public RelayCommand SelectFileCommand { get; }
private void SelectFile()
{
var fileDialog = new OpenFileDialog();
fileDialog.Multiselect = false;
fileDialog.Filter = "Xml files (*.xml)|*.xml";
fileDialog.Title = "选择地图文件";
fileDialog.InitialDirectory = Environment.CurrentDirectory;
fileDialog.CheckFileExists = true;
fileDialog.CheckPathExists = true;
fileDialog.ShowDialog();
_mapFilePath = fileDialog.FileName;
LoadMap();
}
public class PointMessage : ValueChangedMessage<IList<PointFormXml>>
{
public PointMessage(IList<PointFormXml> value) : base(value)
{
}
}
WeakReferenceMessenger.Default.Send<PointMessage>(new PointMessage(ps));
在加载了数据之后,通过MVVM库提供的消息发送组件,给 Canvas
发送数据,在接收到数据之后,就可以将点加入到画布中了
大概效果如下:
代码如下:
WeakReferenceMessenger.Default.Register<PointMessage>(this, HandlePointAdd);
private void HandlePointAdd(object re, PointMessage m)
{
pointFromXml = m.Value;
AssociatedObject.Children.Clear();
foreach (var p in pointFromXml)
{
var pp = new Ellipse();
pp.Width = 3;
pp.Height = 3;
pp.Fill = new SolidColorBrush(Colors.Red);
pp.Stroke = new SolidColorBrush(Colors.Red);
pp.StrokeThickness = 1;
var tip = new ToolTip();
tip.Content = p.Name;
ToolTipService.SetInitialShowDelay(pp, 100);
pp.ToolTip = tip;
var x = double.Parse(p.XPosition) / 100;
var y = double.Parse(p.YPosition) / 100;
Canvas.SetLeft(pp, x);
Canvas.SetTop(pp, y);
AssociatedObject.Children.Add(pp);
}
}
因为我找遍了资料都没有发现,有画点这个控件的,最后是使用了椭圆并填充了内部的颜色,这样就看起来像是一个点了,同时我还增加了 ToolTip
来作为在画布上点位的信息提示,因为如果直接将点位名称写在了画布上,会造成根本看不清图了
然后最后一个点是,代码中出现的 AssociatedObject
其实就是 Canvas
控件,因为在MVVM的模式的时候,一般是不会直接操作控件的,但是有时候又不得不去做这件事的时候,比如现在这样给画布添加点的时候,不知道是否有类似ListBox的ItemSource的依赖属性提供使用,现在找到的比较方便的方法就是使用 Behavior
public class CanvasBehavior : Behavior<Canvas>
然后在xaml中,将这个类引入使用即可
<Window xmlns:i="http://schemas.microsoft.com/xaml/behaviors">
<Canvas x:Name="c1"
Background="#1a1a1a"
Focusable="False">
<i:Interaction.Behaviors>
<local:CanvasBehavior />
</i:Interaction.Behaviors>
</Canvas>
二、地图缩放
接下来要实现地图的缩放功能,同样需要借助 Behavior
实现,但是这次的组件时放在父容器 Border
中,还有一个问题就是,我们缩放过程中不仅要操作 Border
,还要操作 Canvas
,所以我们还需要建立依赖属性,然后将 Canvas
通过数据绑定传入
public class CanvasBehaviorOnMouse : Behavior<FrameworkElement>
{
/// <summary>
/// 缩放的启动键
/// </summary>
public Key? ModifierKey { get; set; } = null;
/// <summary>
/// 依赖属性,负责将子类画布导入,方便操作
/// </summary>
public Canvas UseInChildren
{
get { return (Canvas)GetValue(UseInChildrenProperty); }
set { SetValue(UseInChildrenProperty, value); }
}
/// <summary>
/// 依赖属性,负责将子类画布导入,方便操作
/// </summary>
public static readonly DependencyProperty UseInChildrenProperty =
DependencyProperty.Register("UseInChildren", typeof(Canvas), typeof(CanvasBehaviorOnMouse));
/// <summary>
/// 重写方法,注册鼠标事件
/// </summary>
protected override void OnAttached()
{
AssociatedObject.MouseWheel += AssociatedObject_MouseWheel;
}
/// <summary>
/// 重写方法,注销鼠标事件
/// </summary>
protected override void OnDetaching()
{
AssociatedObject.MouseWheel -= AssociatedObject_MouseWheel;
}
/// <summary>
/// 鼠标滚轮事件,缩放
/// </summary>
private void AssociatedObject_MouseWheel(object sender, MouseWheelEventArgs e)
{
if (ModifierKey != null && Keyboard.IsKeyDown(ModifierKey.Value))
{
var po = e.GetPosition(AssociatedObject);
var tg = UseInChildren.RenderTransform as TransformGroup;
if (tg is null)
{
tg = new TransformGroup();
}
var scale = e.Delta > 0 ? 1.1 : 1 / 1.1;
tg?.Children.Add(new ScaleTransform(scale, scale, po.X, po.Y));
UseInChildren.RenderTransform = tg;
}
}
}
<Border
x:Name="outside"
Background="#1a1a1a"
ClipToBounds="True">
<i:Interaction.Behaviors>
<local:CanvasBehaviorOnMouse ModifierKey="LeftCtrl" UseInChildren="{Binding ElementName=c1}"/>
</i:Interaction.Behaviors>
<Canvas x:Name="c1"
Background="#1a1a1a"
Focusable="False">
<i:Interaction.Behaviors>
<local:CanvasBehavior />
</i:Interaction.Behaviors>
</Canvas>
</Border>
三、拖拽平移、画矩形
接着实现鼠标左键拖拽以及左键画图。由于两者都是使用左键,所以我提供了单选框,选择不同的操作模式,该部分在ViewModel中,然后在数据发生变化的时候,将消息传到 Behavior
中,以此区分两种模式
ViewModel
private bool _isSelectWatchButton;
public bool IsSelectWatchButton
{
get => _isSelectWatchButton;
set
{
SetProperty(ref _isSelectWatchButton, value);
_ = value
? WeakReferenceMessenger.Default.Send<ChangeSignalMessage>(new ChangeSignalMessage(ChangeSignal.Drag))
: WeakReferenceMessenger.Default.Send<ChangeSignalMessage>(new ChangeSignalMessage(ChangeSignal.Draw));
}
}
Behavior
/// <summary>
/// 鼠标移动事件,
/// 在查看模式,并且已经在移动中时,变换整个画布的位置
/// 绘制模式下,不断改变矩形的大小和位置
/// </summary>
private void Outside_MouseMove(object sender, MouseEventArgs e)
{
if (_changeSignal == ChangeSignal.Drag && isTranslateStart)
{
var current = e.GetPosition(AssociatedObject);
var v = current - previous;
var tg = UseInChildren.RenderTransform as TransformGroup;
if (tg is null)
{
tg = new TransformGroup();
}
tg?.Children.Add(new TranslateTransform(v.X, v.Y));
previous = current;
UseInChildren.RenderTransform = tg;
}
else if (_changeSignal == ChangeSignal.Draw && _startPoint is not null && _rect is not null)
{
var pos = e.GetPosition(UseInChildren);
var x = Math.Min(pos.X, _startPoint?.X ?? 0);
var y = Math.Min(pos.Y, _startPoint?.Y ?? 0);
var w = Math.Max(pos.X, _startPoint?.X ?? 0) - x;
var h = Math.Max(pos.Y, _startPoint?.Y ?? 0) - y;
_rect.Width = w;
_rect.Height = h;
Canvas.SetLeft(_rect, x);
Canvas.SetTop(_rect, y);
}
}
/// <summary>
/// 在查看模式下,开启移动
/// 绘制模式下,创建矩形
/// </summary>
private void Outside_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (_changeSignal == ChangeSignal.Drag)
{
isTranslateStart = true;
previous = e.GetPosition(AssociatedObject);
e.Handled = true;
}
else if (_changeSignal == ChangeSignal.Draw)
{
_startPoint = e.GetPosition(UseInChildren);
_rect = new Rectangle
{
Stroke = Brushes.SeaGreen,
StrokeThickness = 2
};
Canvas.SetLeft(_rect, _startPoint?.X ?? 0);
Canvas.SetTop(_rect, _startPoint?.X ?? 0);
UseInChildren.Children.Add(_rect);
}
}
/// <summary>
/// 查看模式下,结束移动
/// 绘制模式下,固定矩形,删除临时信息
/// </summary>
private void Outside_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (_changeSignal == ChangeSignal.Drag && isTranslateStart)
{
isTranslateStart = false;
e.Handled = true;
}
else if (_changeSignal == ChangeSignal.Draw)
{
_rect = null;
_startPoint = null;
}
}
这样做不好的是,两种模式的代码混合在一起了,这样非常不好,可以放在别的Behavior中,也就是对同一个事件注册不同的函数,这样拆分之后,方便后期的维护,不过作为小工具来说,这样是最方便的,所以就没改
四、删除矩形
在规划交管的过程中,肯定会出现画错的情况,那么就需要将错误的框删除,删除的过程是:
- 点击选中矩形
- 按下Delete键
那么在使用 Canvas
的时候,就发现,画布中的矩形是没有对点击有反应的,然后我又找到了最简单的方法来实现,官方有提供这么一个工具:VisualTreeHelper.HitTest
,它可以找到,鼠标当前坐标是否落在了某个可视化树下的控件,这样我们就知道了某个矩形是否被点击了
这次是在 Canvas
的 Behavior
中编写
private Rectangle? _select;
private void Select_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (_changeSignal == ChangeSignal.Drag)
{
var po = e.GetPosition((UIElement)sender);
var res = VisualTreeHelper.HitTest(AssociatedObject, po);
if (res != null && res.VisualHit is Rectangle rec)
{
if (_select is not null)
{
_select.Stroke = Brushes.SeaGreen;
_select = null;
}
rec.Stroke = Brushes.AliceBlue;
_select = rec;
e.Handled = true;
}
else if (_select is not null)
{
_select.Stroke = Brushes.SeaGreen;
_select = null;
}
}
}
那么在知道了哪个矩形被点击之后,删除就很简单了,只需要调用 Canvas
提供的删除方法就可以了,但是过程中又发现了一个问题,那就是 Canvas
一般是没有响应键盘事件的,因为它没有获得关注Focus,但是就算我改变了这个属性,依旧是没有得到响应,最后我是给Windows写了个 Triggers
,然后发消息给 Canvas
的 Behavior
,这样虽然有点绕,但是过程很简单
xaml
<Window>
<i:Interaction.Triggers>
<i:EventTrigger EventName="KeyUp">
<i:InvokeCommandAction Command="{Binding DeleteCommand}" PassEventArgsToCommand="True"/>
</i:EventTrigger>
</i:Interaction.Triggers>
ViewModel
public RelayCommand<KeyEventArgs> DeleteCommand { get; }
private void DeletePublish(KeyEventArgs? e)
{
WeakReferenceMessenger.Default.Send<KeyMessage>(new KeyMessage(e?.Key ?? Key.A));
}
Behavior
private void HandleKey(object recipient, KeyMessage message)
{
if (message.Value == Key.Delete && _select is not null)
{
AssociatedObject.Children.Remove(_select);
_select = null;
}
}
如果希望响应某个事件,但是又不需要直接操作控件,希望在MVVM下工作,使用 Triggers
也是非常好用的,这些事件处理代码就写在ViewModel中了
五、保存规则
最后在规划好交管之后,我们需要将规则保存下来,而我对保存的格式比较简单,我只需要知道哪些点被划到一起了,然后一个矩形一个组,所以接下来需要计算落在矩形内的点,都有坐标,所以计算起来还是很简单的
总体流程是:
- 界面按钮点击保存
- saveFileDialog弹出
- ViewModel内的方法发送消息(不在这里做是因为需要操作Canvas控件知道所有矩形,不过如果事先将生成所有矩形都发送到ViewModel中的话,就不需要了,但是这样在删除时又会很麻烦,因为需要删除两份数据,容易错漏)
- 在Canvas的Behavior中,计算得出结果,将结果写入文件
ViewModle
public RelayCommand SaveCommand { get; }
private void SaveFunc()
{
var dialog = new SaveFileDialog();
dialog.Filter = "Xml files (*.xml)|*.xml";
dialog.Title = "保存交管规则文件";
dialog.InitialDirectory = Environment.CurrentDirectory;
dialog.ShowDialog();
WeakReferenceMessenger.Default.Send<SaveMessage>(new SaveMessage(dialog.FileName));
}
Behavior
private void Save(object recipient, SaveMessage message)
{
if (pointFromXml is null)
return;
var res = new List<TrafficDto>();
var count = 0;
foreach (var c in AssociatedObject.Children)
{
if (c is Rectangle r)
{
var x1 = Canvas.GetLeft(r);
var y1 = Canvas.GetTop(r);
var x2 = r.Width + x1;
var y2 = r.Height + y1;
var xMax = Math.Max(x1, x2);
var xMin = Math.Min(x1, x2);
var yMin = Math.Min(y1, y2);
var yMax = Math.Max(y1, y2);
var rule = pointFromXml
.Where(p =>
{
var x = double.Parse(p.XPosition) / 100;
var y = double.Parse(p.YPosition) / 100;
return x < xMax && x > xMin && y > yMin && y < yMax;
})
.Select(p => p.Name)
.Aggregate((i, j) => $"{i},{j}");
if (rule != null)
{
res.Add(new TrafficDto()
{
Id = count++,
Rules = rule
});
}
}
}
SaveToFile(message, res);
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?