WPF中画布Canvas控件中内容缩放
1. Transform Class 简介
Transform类位于命名空间System.Windows.Media下。在WPF中Transform类被广泛应用于绘图元素的变换。作为大部分可视化控件的基类,UIElement类具有这样的静态依赖属性:
// System.Windows.UIElement
public Transform RenderTransform
{
get
{
return (Transform)base.GetValue(UIElement.RenderTransformProperty);
}
set
{
base.SetValue(UIElement.RenderTransformProperty, value);
}
}
[CommonDependencyProperty]
public static readonly DependencyProperty RenderTransformProperty =
DependencyProperty.Register("RenderTransform", typeof(Transform), typeof(UIElement), new PropertyMetadata(Transform.Identity, new PropertyChangedCallback(UIElement.RenderTransform_Changed)));
private static void RenderTransform_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
UIElement uielement = (UIElement)d;
if (!uielement.NeverMeasured && !uielement.NeverArranged && !e.IsASubPropertyChange)
{
uielement.InvalidateArrange();
uielement.AreTransformsClean = false;
}
}
官方文档的注解:
A render transform does not regenerate layout size or render size information. Render transforms are typically intended for animating or applying a temporary effect to an element. For example, the element might zoom when focused or moused over, or might jitter on load to draw the eye to that part of the user interface (UI).
故可以通过更改UIElement的RenderTransform属性达到控件变换的目的,且开销较小。
注意到Transform类为抽象类,派生类型有:
- System.Windows.Media.MatrixTransform
- System.Windows.Media.TranslateTransform
- System.Windows.Media.RotateTransform
- System.Windows.Media.ScaleTransform
- System.Windows.Media.SkewTransform
- System.Windows.Media.TransformGroup
由于继承自System.Windows.Media.Transform ,上述各种变换中都具有Value的只读属性,其返回 Matrix 结构。
2. 画布Canvas控件中内容缩放方法简述
网上存在一些解决方案,是通过给Canvas外层添加Border,缩放变换Canvas本省来达到缩放的目的。该方法无法控制Canvas中某一控件不缩放,或各控件非同时缩放,故这里不使用该方法进行缩放,而是监听Canvas的MouseWheel事件并遍历Canvas.Children,对子控件单独控制。控制的基本思想在于,为子控件附加CanZoom附加属性,并附加子控件各自的缩放信息,以此为根据进行缩放变换。
为方便起见,下节直接构造一个新的布局控件 CanvasEx ,其继承于Canvas。
3. CanvasEx具体实现及解读
废话不多说,先上代码:
public class CanvasEx : Canvas
{
private bool _onKeyboardCommand = false;
private bool _onMouseButtonCommand = false;
private Point _previousDraggingPoint;
public static Cursor DragCursor => new Cursor(AppDomain.CurrentDomain.BaseDirectory + "DragHand.cur");
private Cursor _previousCursor;
[Browsable(false)]
private bool IsDragging
{
get { return (bool)GetValue(IsDraggingProperty); }
set { SetValue(IsDraggingProperty, value); }
}
// Using a DependencyProperty as the backing store for IsDragging. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsDraggingProperty =
DependencyProperty.Register("IsDragging", typeof(bool), typeof(CanvasEx), new PropertyMetadata(false));
[Browsable(false)]
private bool IsZooming
{
get { return (bool)GetValue(IsZoomingProperty); }
set { SetValue(IsZoomingProperty, value); }
}
// Using a DependencyProperty as the backing store for IsZooming. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsZoomingProperty =
DependencyProperty.Register("IsZooming", typeof(bool), typeof(CanvasEx), new PropertyMetadata(false));
public static bool GetCanDrag(DependencyObject obj)
{
return (bool)obj.GetValue(CanDragProperty);
}
public static void SetCanDrag(DependencyObject obj, bool value)
{
obj.SetValue(CanDragProperty, value);
}
// Using a DependencyProperty as the backing store for CanDrag. This enables animation, styling, binding, etc...
public static readonly DependencyProperty CanDragProperty =
DependencyProperty.RegisterAttached("CanDrag", typeof(bool), typeof(CanvasEx), new PropertyMetadata(false));
public static Vector GetDraggingVector(DependencyObject obj)
{
return (Vector)obj.GetValue(DraggingVectorProperty);
}
private static void SetDraggingVector(DependencyObject obj, Vector value)
{
obj.SetValue(DraggingVectorProperty, value);
}
// Using a DependencyProperty as the backing store for DraggingVector. This enables animation, styling, binding, etc...
public static readonly DependencyProperty DraggingVectorProperty =
DependencyProperty.RegisterAttached("DraggingVector", typeof(Vector), typeof(CanvasEx), new PropertyMetadata(default(Vector)));
public static bool GetCanZoom(DependencyObject obj)
{
return (bool)obj.GetValue(CanZoomProperty);
}
public static void SetCanZoom(DependencyObject obj, bool value)
{
obj.SetValue(CanZoomProperty, value);
}
// Using a DependencyProperty as the backing store for CanZoom. This enables animation, styling, binding, etc...
public static readonly DependencyProperty CanZoomProperty =
DependencyProperty.RegisterAttached("CanZoom", typeof(bool), typeof(CanvasEx), new PropertyMetadata(false));
public static double GetZoomingScale(DependencyObject obj)
{
return (double)obj.GetValue(ZoomingScaleProperty);
}
private static void SetZoomingScale(DependencyObject obj, double value)
{
obj.SetValue(ZoomingScaleProperty, value);
}
// Using a DependencyProperty as the backing store for ZoomingScale. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ZoomingScaleProperty =
DependencyProperty.RegisterAttached("ZoomingScale", typeof(double), typeof(CanvasEx), new PropertyMetadata(1.0));
public double ScaleSpacing
{
get { return (double)GetValue(ScaleSpacingProperty); }
set { SetValue(ScaleSpacingProperty, value); }
}
// Using a DependencyProperty as the backing store for ScaleSpacing. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ScaleSpacingProperty =
DependencyProperty.Register("ScaleSpacing", typeof(double), typeof(CanvasEx), new PropertyMetadata(0.1));
private static Vector GetScaleMoveVector(DependencyObject obj)
{
return (Vector)obj.GetValue(ScaleMoveVectorProperty);
}
private static void SetScaleMoveVector(DependencyObject obj, Vector value)
{
obj.SetValue(ScaleMoveVectorProperty, value);
}
[Browsable(false)]
// Using a DependencyProperty as the backing store for ScaleMoveVector. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ScaleMoveVectorProperty =
DependencyProperty.RegisterAttached("ScaleMoveVector", typeof(Vector), typeof(CanvasEx), new PropertyMetadata(default(Vector)));
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
this._onMouseButtonCommand = true;
this.BeginDragging(e);
base.OnMouseLeftButtonDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
this.DraggingBehavior(e);
}
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
{
this.EndDragging();
this._onMouseButtonCommand = false;
base.OnMouseLeftButtonUp(e);
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
base.OnMouseWheel(e);
this.ZoomBehavior(e);
}
private void KeyDownJudgment()
{
if (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl))
{
this._onKeyboardCommand = true;
}
else
{
this._onKeyboardCommand = false;
}
}
private void BeginDragging(MouseButtonEventArgs e)
{
if (!this.IsDragging)
{
this.CaptureMouse();
KeyDownJudgment();
bool onDragCommand = this._onKeyboardCommand && this._onMouseButtonCommand;
this.SetValue(CanvasEx.IsDraggingProperty, onDragCommand);
if (onDragCommand)
{
this._previousDraggingPoint = e.GetPosition(this);
this._previousCursor = this.Cursor;
this.Cursor = CanvasEx.DragCursor;
}
else
{
base.ReleaseMouseCapture();
}
}
}
private void DraggingBehavior(MouseEventArgs e)
{
if (this.IsDragging)
{
KeyDownJudgment();
bool onDragCommand = this._onKeyboardCommand && this._onMouseButtonCommand;
if (!onDragCommand)
{
EndDragging();
return;
}
if (e.MouseDevice.LeftButton == MouseButtonState.Pressed)
{
var p = e.GetPosition(this);
if (!VisualTreeHelper.GetContentBounds(this).Contains(p))
{
EndDragging();
return;
}
Vector v = p - this._previousDraggingPoint;
if (v.LengthSquared <= 0)
{
if (e.MouseDevice.Captured == this)
{
base.ReleaseMouseCapture();
}
this.SetValue(CanvasEx.IsDraggingProperty, false);
return;
}
foreach (var child in this.Children)
{
UIElement ch = child as UIElement;
if (CanvasEx.GetCanDrag(ch))
{
try
{
Matrix originalMatTrans = ch.RenderTransform.Value;
originalMatTrans.OffsetX += v.X;
originalMatTrans.OffsetY += v.Y;
ch.RenderTransform = new MatrixTransform(originalMatTrans);
CanvasEx.SetDraggingVector(ch, CanvasEx.GetDraggingVector(ch) + v);
}
catch (Exception exception)
{
throw exception;
}
}
}
this._previousDraggingPoint = e.GetPosition(this);
}
}
}
private void EndDragging()
{
if (base.IsMouseCaptured && this.IsDragging)
{
this.SetValue(CanvasEx.IsDraggingProperty, false);
base.ReleaseMouseCapture();
this.Cursor = this._previousCursor;
}
}
private void ZoomBehavior(MouseWheelEventArgs e)
{
KeyDownJudgment();
if (!this._onKeyboardCommand)
{
return;
}
var p = e.GetPosition(this);
if (!VisualTreeHelper.GetContentBounds(this).Contains(p))
{
return;
}
int delta = e.Delta / 120;
foreach (var child in this.Children)
{
UIElement ch = child as UIElement;
if (CanvasEx.GetCanZoom(ch))
{
var zs = Math.Round(CanvasEx.GetZoomingScale(ch) + this.ScaleSpacing * delta, 2);
if (zs < 0.1 || zs > 10)
{
continue;
}
Matrix mat = ch.RenderTransform.Value;
var ps1 = mat.M11;
var ps2 = mat.M22;
var s1 = Math.Round(mat.M11 + this.ScaleSpacing * delta, 2);
if (s1 <= 0)
{
continue;
}
var s2 = Math.Round(mat.M22 + this.ScaleSpacing * delta, 2);
if (s2 <= 0)
{
continue;
}
mat.M11 = s1;
mat.M22 = s2;
Point rp = ch.TransformToAncestor(this).Transform(new Point());
Rect rect = new Rect(rp, new Point((2 * p.X) - rp.X, (2 * p.Y) - rp.Y));
bool IsTopLeft = IsApproximate((rect.TopLeft - rp).LengthSquared, 0);
bool IsTopRight = IsApproximate((rect.TopRight - rp).LengthSquared, 0);
bool IsBottomLeft = IsApproximate((rect.BottomLeft - rp).LengthSquared, 0);
bool IsBottomRight = IsApproximate((rect.BottomRight - rp).LengthSquared, 0);
rect.Width *= s1 / ps1;
rect.Height *= s2 / ps2;
rect.Offset(p - GetCenterPoint(ref rect));
Vector sMV;
if (IsTopLeft)
{
sMV = rect.TopLeft - rp;
}
else
if (IsTopRight)
{
sMV = rect.TopRight - rp;
}
else
if (IsBottomLeft)
{
sMV = rect.BottomLeft - rp;
}
else
if (IsBottomRight)
{
sMV = rect.BottomRight - rp;
}
else
{
System.Diagnostics.Debug.WriteLine("Error");
continue;
}
mat.OffsetX += sMV.X;
mat.OffsetY += sMV.Y;
ch.RenderTransform = new MatrixTransform(mat);
CanvasEx.SetScaleMoveVector(ch, CanvasEx.GetScaleMoveVector(ch) + sMV);
CanvasEx.SetZoomingScale(ch, zs);
}
}
}
private Point GetCenterPoint(ref Rect rect)
{
return new Point((rect.TopLeft.X + rect.BottomRight.X) * 0.5, (rect.TopLeft.Y + rect.BottomRight.Y) * 0.5);
}
private bool IsApproximate(double value, double referance)
=> value == referance
? true
: value <= referance + 1e-5 && value >= referance - 1e-5;
public void ResetTransform(UIElement child)
{
if (this.Children.Contains(child))
{
var dv = CanvasEx.GetDraggingVector(child);
var sv = CanvasEx.GetScaleMoveVector(child);
Matrix originalMatTrans = child.RenderTransform.Value;
originalMatTrans.OffsetX -= dv.X + sv.X;
originalMatTrans.OffsetY -= dv.Y + sv.Y;
var zs = CanvasEx.GetZoomingScale(child);
originalMatTrans.M11 -= zs - 1;
originalMatTrans.M22 -= zs - 1;
child.RenderTransform = new MatrixTransform(originalMatTrans);
CanvasEx.SetDraggingVector(child, new Vector());
CanvasEx.SetScaleMoveVector(child, new Vector());
CanvasEx.SetZoomingScale(child, 1.0);
}
}
public void ResetTransform()
{
foreach (UIElement child in this.Children)
{
ResetTransform(child);
}
}
}
可以看到,CanvasEx 主要简单实现了拖拽和缩放功能。对于缩放,其核心在于ZoomBehavior方法,下面对其详细解说。
对CanvasEx的内容缩放,我想看到的是,以鼠标当前位置进行缩放,缩放或平移完之后,可以通过一些方法还原到原来位置的效果。
曾经尝试通过对RenderTransform.Value使用Matrix.ScaleAt方法来实现以鼠标当前位置进行缩放。虽然单缩放操作堪称完美(缩小后再放大能回到原来位置),但是一旦牵扯到平移-缩放混合变换时就失效了。在ResetTransform后,总是会出现回到原来位置的现象,目前还未知具体原因,可能是由于矩阵乘法先后的缘故导致的。
所以,我直接采用了(暴力)矩形(Rect 结构)缩放的方法。首先构造一个以鼠标鼠标当前位置为中心,控件坐标为角点的矩形;缩放大小(此时缩放是以矩形左上角点为原点缩放的),并手动平移,使得变换前后的矩形中心对齐,计算变换后的角点。此时可以获得因变换而生成的缩放大小和缩放位移,以附加属性的方式附加在控件上。
此方法对内容缩放变换,不会破坏子控件原来的RenderTransform。
在xaml中可以这样使用CanvasEx的一些属性,例如其存在一个Rectangle控件:
<Rectangle
x:Name="R"
Fill="#FFC72121"
Canvas.Left="150"
Canvas.Top="150"
Width="50"
Height="50"
local:CanvasEx.CanDrag="True"
local:CanvasEx.CanZoom="True"
>
<Rectangle.RenderTransform>
<TranslateTransform X="10" Y="30"/>
</Rectangle.RenderTransform>
</Rectangle>
甚至可以获取缩放或平移的信息与TextBlock绑定:
<!--平移信息-->
<TextBlock
FontSize="14"
FontWeight="Bold"
Canvas.Left="330"
Canvas.Bottom="20"
Text="{Binding Path=(local:CanvasEx.DraggingVector), Mode=OneWay, ElementName=R}"
/>
<!--缩放信息-->
<TextBlock
FontSize="14"
FontWeight="Bold"
Canvas.Left="200"
Canvas.Bottom="80"
Text="{Binding Path=(local:CanvasEx.ZoomingScale), Mode=OneWay, ElementName=R}"
/>