开发环境
VS2022
.net core6
需求
需要在一条直线上可随意移动一个点,这个点一定要在这条直线上,不可移出直线。也就是说A点到B点的直线就是这个点的移动范围。
需求深入分析
这个需求看上去,感觉完全可以使用Slider实现,因为Slider上已经将可移动的那个点(Slider的控制柄)完全限制在Slider上了。但深入沟通交流后发现,这条线的两个端点是需要绑定一个平面坐标的,并且两个端点可能会实时变化,那么用slider实现后,要改变它在平面上的位置,就不太方便了,需要通过复杂的计算,才能准确放置它的位置。
转而打算用Line + Ellipse来实现,Line由两个端点A(X1,Y1)B(X2,Y2)就决定了,要改变它的平面位置,只要将两个坐标点绑定给A、B两点即可。那么就只需要实现控制点无论怎么移动就在这条直线上即可,即是将不在直线上的光标位置转化为在直线上的x、y坐标值,然后将求出的坐标值赋值给点(注意是点的圆心在直线上,这个是隐藏需求)即可。相对Slider要简单很多。
注:文中提到的x1、y1均表示Line的A点坐标(x2、y2同理)。
转化为数学问题
因直线的AB两点已经定下来,也就是说(x1,y1)与(x2,y2)为已知,那么根据直线方程:y=kx+b,k为斜率(要注意直线为x轴垂线时需要特殊考虑),b为截距(即x=0时与y轴的交点)。
那么k=(y2-y1)/(x2-x1),b=y1- k x1。
同时直线X的范围限制在[x1,y1]这个闭区间即可,当斜率k存在时,Y就完全不需要限制它的区间(X的区间就限制了Y的区间);当斜率不存在时,即直线为x轴的垂线时,则需要限制y的区间在[y1,y2]的闭区间。开发中可以将X与Y均限制在直线区间内,然后再求对应直线点的值。
开发中b可以不求它,因为在这条直线段上的点的y值可以通过y=y1+k(x-x1), x为通过获取光标位置坐标x值;而斜率不存在时,b也不存在。
UI与代码
通过分析,这个功能仅需要Line与Ellipse即可,而为了实现Ellipse在后续移动,将这两个控件作为canvas的子控件,这样Ellipse后续位置的变动就可以很好的设置,通过Canvas.SetLeft和Canvas.SetTop即可将点位置重新设置。
UI端Xaml代码如下:
<Canvas>
<Line
x:Name="line"
Stroke="Black"
StrokeThickness="2"
X1="10"
X2="400"
Y1="{Binding ElementName=line, Path=X1}"
Y2="400" />
<Ellipse
Name="movingCircle"
Canvas.Left="0"
Canvas.Top="0"
Width="20"
Height="20"
Fill="Blue"
Opacity="0.6" />
</Canvas>
注意:
上述Line暂未用绑定,此可按自己需求进行相应的位置数据绑定。
另Ellipse的坐标设置的是(0,0),以便初始时Line的A点与Ellipse的圆心重合。
WINUI后台代码如下:
movingCircle.PointerPressed += (sender, e) =>
{
var ellipse = sender as Ellipse;
var startPoint = e.GetCurrentPoint(ellipse).Position;
ellipse.CapturePointer(e.Pointer);
ellipse.PointerMoved += (s, me) =>
{
if (ellipse.CapturePointer(e.Pointer))
{
if (line.X1 == line.X2 && line.Y1 == line.Y2)//这就是一个点了,一个点计算个啥?
{
return;
}
var currentPosition = me.GetCurrentPoint(canvas).Position;
var x = Math.Max(line.X1, Math.Min(line.X2, currentPosition.X));
double y = Math.Max(line.Y1, Math.Min(line.Y2, currentPosition.Y));
if (line.X1 == line.X2 || line.Y1 == line.Y2)
{
//do nothing
}
else
{
var slope = (line.Y2 - line.Y1) / (line.X2 - line.X1);//X2==X1时,需要特殊考虑
var slope1 = (line.X2 - line.X1) / (line.Y2 - line.Y1);//y2==y1时,需要特殊处理
if (Math.Abs(slope) > Math.Abs(slope1))
{
x = line.X1 + slope1 * (y - line.Y1);
}
else
y = line.Y1 + slope * (x - line.X1);
}
var r = ellipse.Width / 2;
Canvas.SetLeft(ellipse, x - r);
Canvas.SetTop(ellipse, y - r);
}
};
ellipse.PointerReleased += (s, me) =>
{
ellipse.ReleasePointerCapture(e.Pointer);
ellipse.PointerMoved -= (s, me2) => { };
ellipse.PointerReleased -= (s, me3) => { };
};
};
WPF后台代码如下(UI代码完全一样):
public class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
movingCircle.MouseLeftButtonDown += (sender, e) =>
{
var ellipse = sender as Ellipse;
var b = ellipse.CaptureMouse();
ellipse.MouseMove += (s, me) =>
{
if (ellipse.IsMouseCaptured)
{
if (line.X1 == line.X2 && line.Y1 == line.Y2)//这就是一个点了,一个点计算个啥?
{
return;
}
var currentPosition = me.GetPosition(canvas);
var x = Math.Max(line.X1, Math.Min(line.X2, currentPosition.X));
double y = Math.Max(line.Y1, Math.Min(line.Y2, currentPosition.Y));
if (line.X1 == line.X2 || line.Y1 == line.Y2)
{
//do nothing
}
else
{
var slope = (line.Y2 - line.Y1) / (line.X2 - line.X1);//X2==X1时,需要特殊考虑
var slope1 = (line.X2 - line.X1) / (line.Y2 - line.Y1);//y2==y1时,需要特殊处理
if (Math.Abs(slope) > Math.Abs(slope1))
{
x = line.X1 + slope1 * (y - line.Y1);
}
else
y = line.Y1 + slope * (x - line.X1);
}
var r = ellipse.Width / 2;
Canvas.SetLeft(ellipse, x - r);
Canvas.SetTop(ellipse, y - r);
}
};
ellipse.MouseLeftButtonUp += (s, me) =>
{
ellipse.ReleaseMouseCapture();
ellipse.MouseMove -= (s, me2) => { };
ellipse.MouseLeftButtonUp -= (s, me3) => { };
};
};
}
}
注:后台代码中控件获取指针焦点后,即WINUI的CapturePointer与WPF的CaptureMouse,一定要Release,WINUI中通过ReleasePointerCapture方法,WPF通过ReleaseMouseCapture。
特别注意
斜率K过大时,X移动过小的距离,Y就可能到达直线的端点。也就是说变化率过大会导致调节变得异常困难。
为了避免这个问题,在处理的过程中分别求了斜率slope和它的倒数slope1,然后比较它们绝对值(若斜率是-50,那么它的倒数是-1/50,-1/50的是大于-50的,但调节变化率则应该是-1/50是小于-50的)的大小,用较小的一个来计算当前位置对应在直线上的点位置。
|slope|>|1/slope|时, x = X1 + 1/slope * (y - Y1) y为获取到的当前光标位置的y坐标值。
|slope|<=|1/slope|时,y = Y1 + slope * (x - X1) x为获取到的当前光标位置的x坐标值。
注意事项
- 后台代码中控件获取指针焦点后,即WINUI的CapturePointer与WPF的CaptureMouse,一定要释放;WINUI中通过ReleasePointerCapture方法,WPF通过ReleaseMouseCapture。
- 在后台代码中一定要将在光标移动时绑定的事件移除,同个控件多次订阅相同事件是无益的,性能会有一定下降,还可能导致对象回收不及时或长期持有对象而出现内存相关bug。
- 要注间线上移动点Ellipse的圆心位置是否在直线上。
- 注意斜率导致的变化过大问题,同时处理斜率过大的边界条件x1=x2要考虑,y1=y2的情况也要考虑。x1=x2 && y1=y2的极端情况也要一并考虑进去。
- 注意获取光标位置时通过获取点的容器的方式来获取,直接通过获取点的光标位置,则会因点在快速移动,move事件处理不及时可能导致出现点跳动问题,在测试时斜率过大(绝对值过大)越容易出现点在直线上跳动。
- 要注意边界条件的考虑,如直线方程中很可能不会考虑到直线为x轴垂线的情况。
- 以上代码若有必要可以封装为一个UserControl,以供后续的复用。