承接 Part 1 的移動/縮放/旋轉基礎,Part 2 實作專業設計器必備的四大基礎設施:可滾動可擴展設計面板、縮略圖導航、橡皮筋框選、工具箱拖拽。
問題:拖拽元素超出 Canvas 邊界時,Canvas 不會自動增大,導致元素「丟失」。
解決:重寫 `MeasureOverride`,根據所有子元素位置與期望尺寸動態計算 Canvas 尺寸:
public class DesignerCanvas : Canvas {
protected override Size MeasureOverride(Size constraint) {
Size size = new Size();
foreach (UIElement element in Children) {
double left = Canvas.GetLeft(element); if (double.IsNaN(left)) left = 0;
double top = Canvas.GetTop(element); if (double.IsNaN(top)) top = 0;
element.Measure(constraint);
Size desired = element.DesiredSize;
if (!double.IsNaN(desired.Width) && !double.IsNaN(desired.Height)) {
size.Width = Math.Max(size.Width, left + desired.Width);
size.Height = Math.Max(size.Height, top + desired.Height);
}
}
size.Width += 10; size.Height += 10; // 美觀邊距
return size;
}
}
外層包裹 `ScrollViewer`,即可實現無限畫布效果。

核心原理:
public class ZoomBox : Control {
private Thumb _zoomThumb; private Canvas _zoomCanvas; private Slider _zoomSlider;
private ScaleTransform _scaleTransform; private DesignerCanvas _designerCanvas;
public ScrollViewer ScrollViewer { get; set; } // 依賴屬性綁定
public override void OnApplyTemplate() {
base.OnApplyTemplate();
_designerCanvas = ScrollViewer.Content as DesignerCanvas;
_zoomThumb = Template.FindName("PART_ZoomThumb", this) as Thumb;
_zoomCanvas = Template.FindName("PART_ZoomCanvas", this) as Canvas;
_zoomSlider = Template.FindName("PART_ZoomSlider", this) as Slider;
_designerCanvas.LayoutUpdated += DesignerCanvas_LayoutUpdated;
_zoomThumb.DragDelta += Thumb_DragDelta;
_zoomSlider.ValueChanged += ZoomSlider_ValueChanged;
_scaleTransform = new ScaleTransform();
_designerCanvas.LayoutTransform = _scaleTransform;
}
void ZoomSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) {
double scale = e.NewValue / e.OldValue;
double halfH = ScrollViewer.ViewportHeight / 2;
double newV = (ScrollViewer.VerticalOffset + halfH) * scale - halfH;
double halfW = ScrollViewer.ViewportWidth / 2;
double newH = (ScrollViewer.HorizontalOffset + halfW) * scale - halfW;
_scaleTransform.ScaleX *= scale; _scaleTransform.ScaleY *= scale;
ScrollViewer.ScrollToHorizontalOffset(newH);
ScrollViewer.ScrollToVerticalOffset(newV);
}
void Thumb_DragDelta(object sender, DragDeltaEventArgs e) {
InvalidateScale(out double scale, out double xOff, out double yOff);
ScrollViewer.ScrollToHorizontalOffset(ScrollViewer.HorizontalOffset + e.HorizontalChange / scale);
ScrollViewer.ScrollToVerticalOffset(ScrollViewer.VerticalOffset + e.VerticalChange / scale);
}
void DesignerCanvas_LayoutUpdated(object sender, EventArgs e) {
InvalidateScale(out double scale, out double xOff, out double yOff);
_zoomThumb.Width = ScrollViewer.ViewportWidth * scale;
_zoomThumb.Height = ScrollViewer.ViewportHeight * scale;
Canvas.SetLeft(_zoomThumb, xOff + ScrollViewer.HorizontalOffset * scale);
Canvas.SetTop(_zoomThumb, yOff + ScrollViewer.VerticalOffset * scale);
}
void InvalidateScale(out double scale, out double xOff, out double yOff) {
double w = _designerCanvas.ActualWidth * _scaleTransform.ScaleX;
double h = _designerCanvas.ActualHeight * _scaleTransform.ScaleY;
double x = _zoomCanvas.ActualWidth; double y = _zoomCanvas.ActualHeight;
double scaleX = x / w; double scaleY = y / h;
scale = Math.Min(scaleX, scaleY);
xOff = (x - scale * w) / 2; yOff = (y - scale * h) / 2;
}
}
<Style TargetType="{x:Type s:ZoomBox}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type s:ZoomBox}">
<Border CornerRadius="1" BorderThickness="1" Background="#EEE" BorderBrush="DimGray">
<Expander IsExpanded="True" Background="Transparent">
<Border BorderBrush="DimGray" BorderThickness="0,1,0,0" Height="180">
<Grid>
<Canvas Margin="5" Name="PART_ZoomCanvas">
<Canvas.Background>
<VisualBrush Stretch="Uniform" Visual="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=ScrollViewer.Content}"/>
</Canvas.Background>
<Thumb Name="PART_ZoomThumb" Cursor="SizeAll">
<Thumb.Style><Style TargetType="Thumb"><Setter Property="Template"><Setter.Value>
<ControlTemplate TargetType="Thumb"><Rectangle StrokeThickness="1" Stroke="Black" Fill="Transparent"/></ControlTemplate>
</Setter.Value></Setter></Style></Thumb.Style>
</Thumb>
</Canvas>
</Grid>
</Border>
<Expander.Header>
<Grid>
<Slider Name="PART_ZoomSlider" VerticalAlignment="Center" HorizontalAlignment="Center"
Ticks="25,50,75,100,125,150,200,300,400,500" Minimum="25" Maximum="500" Value="100"
IsSnapToTickEnabled="True"/>
<TextBlock Text="{Binding ElementName=PART_ZoomSlider, Path=Value}" VerticalAlignment="Center" HorizontalAlignment="Right" Margin="0,0,14,0"/>
<TextBlock Text="%" VerticalAlignment="Center" HorizontalAlignment="Right" Margin="1,0,2,0"/>
</Grid>
</Expander.Header>
</Expander>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

利用 `AdornerLayer` 繪製半透明矩形框,即時檢測框內元素並設定 `IsSelected`:
位於視窗所有控件最上層,專門承載裝飾視覺(游標、高亮、框選、連線預覽)。透過 `AdornerLayer.GetAdornerLayer(element)` 取得。


protected override void OnMouseMove(MouseEventArgs e) {
base.OnMouseMove(e);
if (e.LeftButton != MouseButtonState.Pressed) _dragStartPoint = null;
if (_dragStartPoint.HasValue) {
var layer = AdornerLayer.GetAdornerLayer(this);
if (layer != null) {
var adorner = new RubberbandAdorner(this, _dragStartPoint.Value);
layer.Add(adorner);
}
e.Handled = true;
}
}
public class RubberbandAdorner : Adorner {
private readonly DesignerCanvas _designerCanvas;
private readonly Point _startPoint;
private Point? _endPoint;
private readonly Rectangle _rubberband = new Rectangle { Stroke = Brushes.Blue, StrokeThickness = 1, StrokeDashArray = new DoubleCollection { 2 }, Fill = Brushes.Transparent };
public RubberbandAdorner(DesignerCanvas designerCanvas, Point startPoint) : base(designerCanvas) {
_designerCanvas = designerCanvas; _startPoint = startPoint;
VisualCollection visuals = new VisualCollection(this) { _rubberband };
}
protected override void OnMouseMove(MouseEventArgs e) {
if (e.LeftButton == MouseButtonState.Pressed) {
if (!IsMouseCaptured) CaptureMouse();
_endPoint = e.GetPosition(this);
UpdateRubberband(); UpdateSelection();
e.Handled = true;
}
}
void UpdateRubberband() {
double left = Math.Min(_startPoint.X, _endPoint.Value.X);
double top = Math.Min(_startPoint.Y, _endPoint.Value.Y);
double width = Math.Abs(_startPoint.X - _endPoint.Value.X);
double height = Math.Abs(_startPoint.Y - _endPoint.Value.Y);
_rubberband.Width = width; _rubberband.Height = height;
Canvas.SetLeft(_rubberband, left); Canvas.SetTop(_rubberband, top);
}
void UpdateSelection() {
Rect band = new Rect(_startPoint, _endPoint.Value);
foreach (DesignerItem item in _designerCanvas.Children) {
Rect itemRect = VisualTreeHelper.GetDescendantBounds(item);
Rect itemBounds = item.TransformToAncestor(_designerCanvas).TransformBounds(itemRect);
item.IsSelected = band.Contains(itemBounds);
}
}
}

Toolbox:繼承 `ItemsControl`,使用 `WrapPanel` 佈局,容器為 `ToolboxItem`。
public class Toolbox : ItemsControl {
public Size DefaultItemSize { get; set; } = new Size(65, 65);
protected override DependencyObject GetContainerForItemOverride() => new ToolboxItem();
protected override bool IsItemItsOwnContainerOverride(object item) => item is ToolboxItem;
}
ToolboxItem:拖拽時序列化 Content 為 XAML,透過 `DragDrop.DoDragDrop` 傳遞:
public class ToolboxItem : ContentControl {
private Point? _dragStartPoint;
protected override void OnPreviewMouseDown(MouseButtonEventArgs e) {
base.OnPreviewMouseDown(e); _dragStartPoint = e.GetPosition(this);
}
protected override void OnMouseMove(MouseEventArgs e) {
base.OnMouseMove(e);
if (e.LeftButton != MouseButtonState.Pressed) { _dragStartPoint = null; return; }
if (_dragStartPoint.HasValue) {
Point pos = e.GetPosition(this);
if (Math.Abs(pos.X - _dragStartPoint.Value.X) >= SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(pos.Y - _dragStartPoint.Value.Y) >= SystemParameters.MinimumVerticalDragDistance) {
string xaml = XamlWriter.Save(Content);
var data = new DataObject("DESIGNER_ITEM", xaml);
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy);
}
e.Handled = true;
}
}
}
<Style TargetType="{x:Type s:Toolbox}">
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<WrapPanel ItemWidth="{Binding DefaultItemSize.Width, RelativeSource={RelativeSource AncestorType=s:Toolbox}}"
ItemHeight="{Binding DefaultItemSize.Height, RelativeSource={RelativeSource AncestorType=s:Toolbox}}" Margin="0,5,0,5"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template"><Setter.Value><ControlTemplate>
<Border BorderThickness="{TemplateBinding Border.BorderThickness}" Padding="{TemplateBinding Control.Padding}"
BorderBrush="{TemplateBinding Border.BorderBrush}" Background="{TemplateBinding Panel.Background}">
<ScrollViewer VerticalScrollBarVisibility="Auto"><ItemsPresenter/></ScrollViewer>
</Border></ControlTemplate></Setter.Value></Setter>
</Style>

public class DesignerItem : ContentControl {
public static readonly DependencyProperty IsSelectedProperty = DependencyProperty.Register(
"IsSelected", typeof(bool), typeof(DesignerItem), new FrameworkPropertyMetadata(false));
public bool IsSelected { get => (bool)GetValue(IsSelectedProperty); set => SetValue(IsSelectedProperty, value); }
}
protected override void OnPreviewMouseDown(MouseButtonEventArgs e) {
base.OnPreviewMouseDown(e);
var designer = VisualTreeHelper.GetParent(this) as DesignerCanvas;
if (designer != null) {
if ((Keyboard.Modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
IsSelected = !IsSelected; // Ctrl/Shift 多選切換
else if (!IsSelected) {
designer.DeselectAll(); IsSelected = true; // 單選
}
}
e.Handled = false;
}
<Style TargetType="{x:Type s:DesignerItem}">
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="{x:Type s:DesignerItem}">
<Grid DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}}">
<s:MoveThumb x:Name="PART_MoveThumb" Cursor="SizeAll" Template="{StaticResource MoveThumbTemplate}"/>
<ContentPresenter x:Name="PART_ContentPresenter" Content="{TemplateBinding Content}" Margin="{TemplateBinding Padding}"/>
<s:ResizeDecorator x:Name="PART_DesignerItemDecorator"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="PART_DesignerItemDecorator" Property="ShowDecorator" Value="True"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate></Setter.Value></Setter>
</Style>
預設整個透明矩形可移動。設定 `MoveThumbTemplate` 限制僅形狀內部可拖曳:
<Path Data="M 9,2 11,7 17,7 12,10 14,15 9,12 4,15 6,10 1,7 7,7 Z" Fill="Orange">
<s:DesignerItem.MoveThumbTemplate>
<ControlTemplate>
<Path Data="M 9,2 11,7 17,7 12,10 14,15 9,12 4,15 6,10 1,7 7,7 Z" Fill="Transparent" Stretch="Fill"/>
</ControlTemplate>
</s:DesignerItem.MoveThumbTemplate>
</Path>

Part 2 完成了專業設計器的四大基建:
這些組件共同構成了「畫布無限、導航便捷、選擇直觀、拖拽生成」的設計器核心體驗。