WPF控件开发思路,以视觉软件ROI绘制为例
WPF控件开发思路,以视觉软件ROI绘制为例
此原则不仅仅引用于ROI绘制,而是WPF控件开发一般性原则,也可用于流程图编辑器等控件的开发中;
原则:
1.使用数据驱动、属性驱动,而不是事件驱动
2.ViewModel不依赖与WPF概念或相关类
3.交互行为,如事件响应,图形渲染,应该被封装到自定义控件中
为什么:
- WPF的MVVM模式,就是要将编写业务和复杂的UI交互分离,最好能独立开,将编写业务的人从复杂的UI交互中解放出来;
- 如果在ViewModels中包含着UI概念和UI交互,如对鼠标,键盘,UI的显示控制,那就和Winform没什么分别,发挥不出MVVM的优势。
- 有些人可以写业务,对数据读写后端比较擅长,有些人对前端图形化比较擅长,通过MVVM可以职责划分和分工协作;
自定义控件与用户控件的区别:
- 技术上,二者都继承Control类,区别在于自定义空间可以自定义一个模板,已改变其外观,但其本质仍有其后台代码定义的,如Button,即使改变了外观,其后台代码不变,本质仍然是一个Button。
- 应用上,自定义控件应该封装了交互行为和绘制渲染行为,并通过暴露Command传递给ViewModel,并通过暴露的依赖属性与ViewModel中的数据同步,或根据ViewModel的数据来呈现。
- 用户控件,一般带有数据上下文信息,并组合一个或多个自定义控件,可以理解为ViewModel的视图映射,一般情况下,一个用户控件对应一个ViewModel,
- 二者区别:自定义控件更加偏向于通用的组件,用户控件更加偏向于面向特定的业务逻辑(这是由于它和ViewModel是对应的)。自定义控件一般不绑定一个数据上下文信息,用户控件一般对应着ViewModel的数据上下文
具体实操:
现以ROIs绘制Demo为例,来解释一般强交互业务的开发思路:
数据模型,ROIs应该是一组对区域的描述的数据结构,以ArcROI为例,包含圆心及半径的像素坐标表示,及包含起始角度、终止角度,位置像素坐标
1 public class ArcROIDModel: 2 { Point CirclePosition, 4 Double Radius, 5 Double StartAngle, 6 Double EndAngle, 7 }
视图
初步想到应该可以对应一个Shape,该Shape具有Radius, StartAngle, EndAngle,三个依赖属性,来渲染自身,至于为什么没有对应上述模型类中的 CirclePosition、Radius
的依赖属性,很简单,因为CirclePosition不属于Shape控件本身,而是由其与包含该元素的布局容器共同决定的;而Radius我们可以借助于控件自身的Width/Height依赖属性来实现(通过Radius可以计算出Width/Height依赖属性)
第一步,我们先设计一个这样的Shape图形类,命名为RingShape,继承与WPF框架提供的Shape基类,我们很容易就设计出这样一个类,以下是代码片段:
1 public sealed class RingShape : Shape 2 { 5 public static readonly DependencyProperty StartAngleProperty = 6 DependencyProperty.Register("StartAngle", typeof(double), typeof(RingShape), 7 new FrameworkPropertyMetadata(360d, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, 8 new PropertyChangedCallback(OnStartAngleChanged))); 9 10 public double StartAngle 11 { 12 get 13 { 14 return (double)GetValue(StartAngleProperty); 15 } 16 set 17 { 18 SetValue(StartAngleProperty, value); 19 } 20 } 21 22 23 public static readonly DependencyProperty EndAngleProperty = 24 DependencyProperty.Register("EndAngle", typeof(double), typeof(RingShape), 25 new FrameworkPropertyMetadata(360d, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, 26 new PropertyChangedCallback(OnEndAngleChanged), new CoerceValueCallback(CoerceEndAngleValue))); 27 28 public double EndAngle 29 { 30 get 31 { 32 return (double)GetValue(EndAngleProperty); 33 } 34 set 35 { 36 SetValue(EndAngleProperty, value); 37 } 38 } 39 40 ... 41 42 }
图形的渲染功能已经完成。
现在要考虑用户可以对图形通过拖拽来控制其Radius,StartAngle,EndAngle等,上述RingShape类不满足这种需要,而且从单一职责的角度,Shape也不应该具有这些功能;
所以考虑开发一个自定义控件来组合RingShape,然后增加拖拽式的交互,并将交互行为以Command的方式传递给ViewModel,并通过Binding的方式根据ViewModel中属性的变化来重绘控件
增加新的CustomControl,代码如下:
Xaml:
<converters:AngleToSliceConverter x:Key="AngleToSliceConverter"/> <converters:StartAngleConverter x:Key="StartAngleConverter"/> <Style x:Key="ROIThumbStyle" TargetType="{x:Type Thumb}"> <Setter Property="Stylus.IsPressAndHoldEnabled" Value="false"/> <Setter Property="Background" Value="Transparent"/> <Setter Property="Foreground" Value="Red"/> <Setter Property="Width" Value="10"/> <Setter Property="Height" Value="10"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type Thumb}"> <Rectangle Stroke="{TemplateBinding Foreground}" Fill="{TemplateBinding Background}" StrokeThickness="2" Cursor="SizeAll"/> </ControlTemplate> </Setter.Value> </Setter> </Style> <Style TargetType="{x:Type shapeControl:ArcControl}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type shapeControl:ArcControl}"> <Grid> <Thumb x:Name="MoveTracker" Style="{StaticResource ROIThumbStyle}"/> <Ellipse StrokeDashArray="2 2" StrokeThickness="{TemplateBinding StrokeThickness}" Stroke="Yellow" IsHitTestVisible="False"/> <shapeControl:RingShape SweepDirection="Counterclockwise" StartAngle="{TemplateBinding StartAngle,Converter={StaticResource StartAngleConverter}}" Slice="{TemplateBinding ArcAngle,Converter={StaticResource AngleToSliceConverter}}" Mode="Slice" Stroke="Red" StrokeThickness="{TemplateBinding StrokeThickness}" IsHitTestVisible="False"/> <Thumb x:Name="StartAngleTracker" Style="{StaticResource ROIThumbStyle}"> <Thumb.RenderTransform> <MatrixTransform/> </Thumb.RenderTransform> </Thumb> <Thumb x:Name="EndAngleTracker" Style="{StaticResource ROIThumbStyle}"> <Thumb.RenderTransform> <MatrixTransform/> </Thumb.RenderTransform> </Thumb> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
Code behind:
1 public class ArcControl : Control 2 { 3 private Thumb _StartAngleTracker; 4 private Thumb _EndAngleTracker; 5 private Thumb _MoveTracker; 6 static ArcControl() 7 { 8 DefaultStyleKeyProperty.OverrideMetadata(typeof(ArcControl), new FrameworkPropertyMetadata(typeof(ArcControl))); 9 } 10 11 public ArcControl() 12 { 13 Loaded += ArcControl_Loaded; 14 } 15 16 public override void OnApplyTemplate() 17 { 18 base.OnApplyTemplate(); 19 _MoveTracker = GetTemplateChild("MoveTracker") as Thumb; 20 _StartAngleTracker = GetTemplateChild("StartAngleTracker") as Thumb; 21 _EndAngleTracker = GetTemplateChild("EndAngleTracker") as Thumb; 22 23 _MoveTracker.DragDelta += _MoveTracker_DragDelta;//movetracker抽象出来做成组件 24 _StartAngleTracker.DragDelta += _StartAngleTracker_DragDelta; 25 _EndAngleTracker.DragDelta += _EndAngleTracker_DragDelta; 26 } 27 28 private void _EndAngleTracker_DragDelta(object sender, DragDeltaEventArgs e) 29 { 30 var center = new Point(RenderSize.Width / 2, RenderSize.Height / 2); 31 32 var startVector = GetPointByAngle(StartAngle) - center; 33 var endVector = (GetPointByAngle(StartAngle + ArcAngle) - center) + new Vector(e.HorizontalChange, e.VerticalChange); 34 var angleDelta = Vector.AngleBetween(endVector,startVector); 35 36 ArcAngle = angleDelta > 0 ? angleDelta : angleDelta + 360;//for test 需要command 发出 37 } 38 39 private void _StartAngleTracker_DragDelta(object sender, DragDeltaEventArgs e) 40 { 41 //var center = new Point(RenderSize.Width / 2, RenderSize.Height / 2); 42 //// var endVector = (GetPointByAngle(StartAngle + ArcAngle) - center) ; 43 44 //var startVector = (GetPointByAngle(StartAngle) - center) + new Vector(e.HorizontalChange, e.VerticalChange); 45 //var angleDelta = Vector.AngleBetween(startVector, GetPointByAngle(StartAngle) - center); 46 ////angleDelta = angleDelta >= 0 ? angleDelta : angleDelta + 360; 47 48 //StartAngle = angleDelta; 49 //ArcAngle -= angleDelta; 50 } 51 52 private void _MoveTracker_DragDelta(object sender, DragDeltaEventArgs e) 53 { 54 var newLeft = double.IsNaN(Canvas.GetLeft(this)) ? 0 : Canvas.GetLeft(this) + e.HorizontalChange; 55 SetCurrentValue(Canvas.LeftProperty, newLeft); 56 57 var newTop = double.IsNaN(Canvas.GetTop(this)) ? 0 : Canvas.GetTop(this) + e.VerticalChange; 58 SetCurrentValue(Canvas.TopProperty, newTop); 59 } 60 61 62 63 private void ArcControl_Loaded(object sender, RoutedEventArgs e) 64 { 65 LocateDragTracker(); 66 } 67 68 #region DP 69 public static readonly DependencyProperty StartAngleProperty = DependencyProperty.Register( 70 nameof(StartAngle), typeof(double), typeof(ArcControl), new PropertyMetadata(default(double), OnPropertyChanged)); 71 72 73 74 public double StartAngle 75 { 76 get { return (double)GetValue(StartAngleProperty); } 77 set { SetValue(StartAngleProperty, value); } 78 } 79 80 public static readonly DependencyProperty ArcAngleProperty = DependencyProperty.Register( 81 nameof(ArcAngle), typeof(double), typeof(ArcControl), new PropertyMetadata(90d, OnPropertyChanged)); 82 83 public double ArcAngle 84 { 85 get { return (double)GetValue(ArcAngleProperty); } 86 set { SetValue(ArcAngleProperty, value); } 87 } 88 89 public static readonly DependencyProperty ShowTrackerProperty = DependencyProperty.Register( 90 nameof(ShowTracker), typeof(bool), typeof(ArcControl), new PropertyMetadata(default(bool), OnPropertyChanged)); 91 92 public bool ShowTracker 93 { 94 get { return (bool)GetValue(ShowTrackerProperty); } 95 set { SetValue(ShowTrackerProperty, value); } 96 } 97 98 99 public static readonly DependencyProperty StrokeThicknessProperty = DependencyProperty.Register( 100 nameof(StrokeThickness), typeof(double), typeof(ArcControl), new PropertyMetadata(2d, OnPropertyChanged)); 101 //复用其他dp才用override dp,此处不用 102 103 public double StrokeThickness 104 { 105 get { return (double)GetValue(StrokeThicknessProperty); } 106 set { SetValue(StrokeThicknessProperty, value); } 107 } 108 109 #endregion 110 111 private Point GetPointByAngle(double angle) 112 { 113 var ellipseRect = new Rect(StrokeThickness / 2, StrokeThickness / 2, RenderSize.Width - StrokeThickness, RenderSize.Height - StrokeThickness); 114 var position = EllipseHelper.PointOfRadialIntersection(ellipseRect, angle); 115 116 //坐标变换 117 var matrix = Matrix.Identity; 118 matrix.Translate(0, -RenderSize.Height); 119 matrix.Scale(1, -1); 120 position = matrix.Transform(position); 121 return position; 122 } 123 124 125 126 127 private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 128 { 129 (d as ArcControl).LocateDragTracker(); 130 } 131 132 private void LocateDragTracker() 133 { 134 if (_StartAngleTracker == null || _EndAngleTracker == null) 135 { 136 return; 137 } 138 139 var startAngleTrackerTargetPosition = GetPointByAngle(StartAngle); //EllipseHelper.PointOfRadialIntersection(ellipseRect, StartAngle); 140 //物体仿射变换 141 var matrix = Matrix.Identity; 142 matrix.Translate(startAngleTrackerTargetPosition.X - RenderSize.Width / 2, startAngleTrackerTargetPosition.Y - RenderSize.Height / 2); 143 _StartAngleTracker.RenderTransform = new MatrixTransform(matrix); 144 145 var endAngleTrackerTargetPosition = GetPointByAngle(StartAngle + ArcAngle); 146 matrix = Matrix.Identity; 147 matrix.Translate(endAngleTrackerTargetPosition.X - RenderSize.Width / 2, endAngleTrackerTargetPosition.Y - RenderSize.Height / 2); 148 _EndAngleTracker.RenderTransform = new MatrixTransform(matrix); 149 } 150 151 152 153 #region Commands 154 public static readonly DependencyProperty ChangeAngleCommandProperty = DependencyProperty.Register( 155 nameof(ChangeAngleCommand), typeof(ICommand), typeof(ArcControl), new PropertyMetadata(null)); 156 157 public ICommand ChangeAngleCommand 158 { 159 get { return (ICommand)GetValue(ChangeAngleCommandProperty); } 160 set { SetValue(ChangeAngleCommandProperty, value); } 161 } 162 163 public static readonly DependencyProperty MoveCommandProperty = DependencyProperty.Register( 164 nameof(MoveCommand), typeof(ICommand), typeof(ArcControl), new PropertyMetadata(null)); 165 166 public ICommand MoveCommand 167 { 168 get { return (ICommand)GetValue(MoveCommandProperty); } 169 set { SetValue(MoveCommandProperty, value); } 170 } 171 172 #endregion 173 174 175 }
至此我们将ViewModel的相关通知属性和Command与该控件绑定,就可以自由的在ViewModel中控制改控件的显示方式,并可以自由地根据业务逻辑响应改变半径,改变StartAngle、EndAngle等命令了。
这样就可以其放置到一个Cavas上,或通过ListControl的数据模板的方式使用该控件了,有很高的复用性。
最终效果如下:

浙公网安备 33010602011771号