Adornerを使いWPFのコントロールを拡大縮小・移動・回転するデモ

コンピュータ

WPF における Adorner は、既存のコントロールの上に重ねて表示される特別な要素で、視覚的な装飾やインタラクティブな「操作ハンドル」を付与するために使われます。Adorner は通常の UI レイアウトとは別の AdornerLayer 上に描画され、その対象(アドーンされる要素)のサイズや位置に応じて動作します。これは単なる描画だけでなく、ユーザーが操作可能なハンドルや視覚フィードバックを追加するのに非常に便利な仕組みです。

本記事で紹介するデモプログラムでは、Adorner を使って Canvas 上の任意のコントロールに対して次のような編集操作を可能にしています:

  • 選択時の枠表示とハンドル表示

  • 移動(ドラッグ)

  • 拡大・縮小(リサイズ)

  • 回転操作

  • 削除操作

これにより、WPF 上で簡易的な「デザイン編集 UI」や「配置・調整インターフェイス」を手軽に実装できます。

Adornerのデモプログラム

ソースコード

ファイル名:AdornerDesignerDemo.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:MainWindow.xaml.cs

using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;

namespace AdornerDesignerDemo;

public partial class MainWindow : Window
{
    private UIElement? _selected;
    private SelectionAdorner? _adorner;

    public MainWindow()
    {
        InitializeComponent();
        Loaded += (_, __) =>
        {
            DesignSurface.Focus();
            UpdateZoomText();
        };
    }

    // ----------------------------
    // Add controls
    // ----------------------------
    private void AddRectangle_Click(object sender, RoutedEventArgs e)
    {
        var rect = new Border
        {
            Width = 220,
            Height = 140,
            Background = new SolidColorBrush(Color.FromRgb(70, 120, 220)),
            BorderBrush = Brushes.White,
            BorderThickness = new Thickness(1),
            CornerRadius = new CornerRadius(8)
        };
        AddToSurface(rect, 80, 80);
    }

    private void AddButton_Click(object sender, RoutedEventArgs e)
    {
        var btn = new Button
        {
            Width = 180,
            Height = 60,
            Content = "Drag/Resize me",
            FontSize = 16
        };
        AddToSurface(btn, 120, 140);
    }

    private void AddText_Click(object sender, RoutedEventArgs e)
    {
        var tb = new TextBlock
        {
            Text = "WPF Adorner Demo",
            Foreground = Brushes.White,
            FontSize = 32,
            Background = new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)),
            Padding = new Thickness(10)
        };

        // TextBlock は Width/Height が NaN になりがちなので、選択後のリサイズが効くように初期サイズを付与
        tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
        tb.Width = tb.DesiredSize.Width;
        tb.Height = tb.DesiredSize.Height;

        AddToSurface(tb, 160, 220);
    }

    private void AddToSurface(FrameworkElement element, double left, double top)
    {
        element.Tag = "DesignItem";
        element.Cursor = Cursors.Arrow;

        // クリック選択
        element.MouseLeftButtonDown += Item_MouseLeftButtonDown;

        Canvas.SetLeft(element, left);
        Canvas.SetTop(element, top);
        DesignSurface.Children.Add(element);

        Select(element);
    }

    // ----------------------------
    // Selection handling
    // ----------------------------
    private void Item_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (sender is UIElement el)
        {
            Select(el);
            e.Handled = true;
        }
    }

    private void DesignSurface_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        // 余白クリックで選択解除
        if (e.OriginalSource == DesignSurface)
        {
            ClearSelection();
            DesignSurface.Focus();
        }
    }

    private void Select(UIElement el)
    {
        if (_selected == el) return;

        ClearSelection();
        _selected = el;

        var layer = AdornerLayer.GetAdornerLayer(el);
        if (layer == null) return;

        // 選択アドーナーを付与
        _adorner = new SelectionAdorner((FrameworkElement)el);
        _adorner.RequestDelete += () => DeleteSelected();
        layer.Add(_adorner);

        DesignSurface.Focus();
    }

    private void ClearSelection()
    {
        if (_selected is FrameworkElement fe && _adorner != null)
        {
            var layer = AdornerLayer.GetAdornerLayer(fe);
            layer?.Remove(_adorner);
        }
        _adorner = null;
        _selected = null;
    }

    // ----------------------------
    // Delete
    // ----------------------------
    private void DeleteSelected_Click(object sender, RoutedEventArgs e) => DeleteSelected();

    private void DesignSurface_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Delete)
        {
            DeleteSelected();
            e.Handled = true;
        }
        else if (e.Key == Key.Escape)
        {
            ClearSelection();
            e.Handled = true;
        }
    }

    private void DeleteSelected()
    {
        if (_selected == null) return;

        // 選択解除してから消す
        var toRemove = _selected;
        ClearSelection();
        DesignSurface.Children.Remove(toRemove);
    }

    // ----------------------------
    // Zoom (surface)
    // ----------------------------
    private void ZoomSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        if (SurfaceScale == null) return;
        SurfaceScale.ScaleX = ZoomSlider.Value;
        SurfaceScale.ScaleY = ZoomSlider.Value;
        UpdateZoomText();
    }

    private void UpdateZoomText()
    {
        ZoomText.Text = $"{ZoomSlider.Value:0.00}x";
    }
}

ファイル名:SelectionAdorner.cs

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;

namespace AdornerDesignerDemo;

public sealed class SelectionAdorner : Adorner
{
    public event Action? RequestDelete;

    private readonly VisualCollection _visuals;

    private readonly Thumb _move;
    private readonly Thumb _rotate;

    private readonly Thumb _tl, _tm, _tr, _ml, _mr, _bl, _bm, _br;

    private const double HandleSize = 10;
    private const double RotateHandleSize = 14;
    private const double RotateOffset = 28;
    private const double MinSize = 20;

    private Point _center;
    private double _startAngle;
    private double _startRotation;

    public SelectionAdorner(FrameworkElement adornedElement)
        : base(adornedElement)
    {
        _visuals = new VisualCollection(this);

        EnsureTransforms(adornedElement);

        // ===== ハンドル生成 =====
        _move = CreateThumb(Cursors.SizeAll, 0.0);
        _rotate = CreateRotateThumb();

        _tl = CreateThumb(Cursors.SizeNWSE);
        _tm = CreateThumb(Cursors.SizeNS);
        _tr = CreateThumb(Cursors.SizeNESW);
        _ml = CreateThumb(Cursors.SizeWE);
        _mr = CreateThumb(Cursors.SizeWE);
        _bl = CreateThumb(Cursors.SizeNESW);
        _bm = CreateThumb(Cursors.SizeNS);
        _br = CreateThumb(Cursors.SizeNWSE);

        _visuals.Add(_rotate);
        _visuals.Add(_move);

        _visuals.Add(_tl); _visuals.Add(_tm); _visuals.Add(_tr);
        _visuals.Add(_ml);                     _visuals.Add(_mr);
        _visuals.Add(_bl); _visuals.Add(_bm); _visuals.Add(_br);

        // ===== 移動 =====
        _move.DragDelta += Move_DragDelta;

        // ===== リサイズ =====
        _tl.DragDelta += (_, e) => ResizeFrom(e, left: true, top: true);
        _tm.DragDelta += (_, e) => ResizeFrom(e, top: true);
        _tr.DragDelta += (_, e) => ResizeFrom(e, right: true, top: true);

        _ml.DragDelta += (_, e) => ResizeFrom(e, left: true);
        _mr.DragDelta += (_, e) => ResizeFrom(e, right: true);

        _bl.DragDelta += (_, e) => ResizeFrom(e, left: true, bottom: true);
        _bm.DragDelta += (_, e) => ResizeFrom(e, bottom: true);
        _br.DragDelta += (_, e) => ResizeFrom(e, right: true, bottom: true);

        // ===== 回転 =====
        _rotate.DragStarted += Rotate_DragStarted;
        _rotate.DragDelta += Rotate_DragDelta;

        adornedElement.MouseRightButtonUp += (_, __) =>
            RequestDelete?.Invoke();
    }

    //==================================================
    // Transform
    //==================================================
    private static void EnsureTransforms(FrameworkElement fe)
    {
        if (fe.RenderTransform is TransformGroup) return;

        var group = new TransformGroup();
        group.Children.Add(new ScaleTransform(1, 1));
        group.Children.Add(new RotateTransform(0));
        group.Children.Add(new TranslateTransform());

        fe.RenderTransform = group;
        fe.RenderTransformOrigin = new Point(0.5, 0.5);
    }

    private RotateTransform? Rotate =>
        ((TransformGroup)AdornedElement.RenderTransform).Children[1] as RotateTransform;

    //==================================================
    // Adorner basics
    //==================================================
    protected override int VisualChildrenCount => _visuals.Count;
    protected override Visual GetVisualChild(int index) => _visuals[index];

    protected override void OnRender(DrawingContext dc)
    {
        var fe = (FrameworkElement)AdornedElement;
        var rect = new Rect(0, 0, fe.ActualWidth, fe.ActualHeight);

        var pen = new Pen(Brushes.Orange, 1.5)
        {
            DashStyle = DashStyles.Dash
        };

        dc.DrawRectangle(null, pen, rect);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        var fe = (FrameworkElement)AdornedElement;

        double w = fe.ActualWidth;
        double h = fe.ActualHeight;

        // 移動バー
        _move.Arrange(new Rect(0, 0, w, 18));

        // 回転ハンドル
        _rotate.Arrange(new Rect(
            w / 2 - RotateHandleSize / 2,
            -RotateOffset,
            RotateHandleSize,
            RotateHandleSize));

        Arrange(_tl, 0, 0);
        Arrange(_tm, w / 2, 0);
        Arrange(_tr, w, 0);

        Arrange(_ml, 0, h / 2);
        Arrange(_mr, w, h / 2);

        Arrange(_bl, 0, h);
        Arrange(_bm, w / 2, h);
        Arrange(_br, w, h);

        return finalSize;
    }

    private void Arrange(Thumb t, double x, double y)
    {
        t.Arrange(new Rect(
            x - HandleSize / 2,
            y - HandleSize / 2,
            HandleSize,
            HandleSize));
    }

    //==================================================
    // Move
    //==================================================
    private void Move_DragDelta(object sender, DragDeltaEventArgs e)
    {
        var fe = (FrameworkElement)AdornedElement;

        double x = Canvas.GetLeft(fe);
        double y = Canvas.GetTop(fe);

        if (double.IsNaN(x)) x = 0;
        if (double.IsNaN(y)) y = 0;

        Canvas.SetLeft(fe, x + e.HorizontalChange);
        Canvas.SetTop(fe, y + e.VerticalChange);
    }

    //==================================================
    // Resize
    //==================================================
    private void ResizeFrom(
        DragDeltaEventArgs e,
        bool left = false,
        bool top = false,
        bool right = false,
        bool bottom = false)
    {
        var fe = (FrameworkElement)AdornedElement;

        double x = Canvas.GetLeft(fe);
        double y = Canvas.GetTop(fe);
        if (double.IsNaN(x)) x = 0;
        if (double.IsNaN(y)) y = 0;

        double w = fe.Width;
        double h = fe.Height;

        if (double.IsNaN(w)) w = fe.ActualWidth;
        if (double.IsNaN(h)) h = fe.ActualHeight;

        if (left)
        {
            double nw = Math.Max(MinSize, w - e.HorizontalChange);
            Canvas.SetLeft(fe, x + (w - nw));
            fe.Width = nw;
        }

        if (right)
            fe.Width = Math.Max(MinSize, w + e.HorizontalChange);

        if (top)
        {
            double nh = Math.Max(MinSize, h - e.VerticalChange);
            Canvas.SetTop(fe, y + (h - nh));
            fe.Height = nh;
        }

        if (bottom)
            fe.Height = Math.Max(MinSize, h + e.VerticalChange);

        InvalidateArrange();
    }

    //==================================================
    // Rotate
    //==================================================
    private void Rotate_DragStarted(object sender, DragStartedEventArgs e)
    {
        var fe = (FrameworkElement)AdornedElement;

        _center = fe.TranslatePoint(
            new Point(fe.ActualWidth / 2, fe.ActualHeight / 2),
            Application.Current.MainWindow);

        Point mouse = Mouse.GetPosition(Application.Current.MainWindow);

        _startAngle = Math.Atan2(
            mouse.Y - _center.Y,
            mouse.X - _center.X);

        _startRotation = Rotate!.Angle;
    }

    private void Rotate_DragDelta(object sender, DragDeltaEventArgs e)
    {
        Point mouse = Mouse.GetPosition(Application.Current.MainWindow);

        double angle = Math.Atan2(
            mouse.Y - _center.Y,
            mouse.X - _center.X);

        double delta = angle - _startAngle;

        Rotate!.Angle = _startRotation + delta * 180 / Math.PI;
    }

    //==================================================
    private Thumb CreateThumb(Cursor cursor, double opacity = 1.0)
    {
        return new Thumb
        {
            Width = HandleSize,
            Height = HandleSize,
            Cursor = cursor,
            Opacity = opacity,
            Template = BuildTemplate(Brushes.Orange)
        };
    }

    private Thumb CreateRotateThumb()
    {
        return new Thumb
        {
            Width = RotateHandleSize,
            Height = RotateHandleSize,
            Cursor = Cursors.Hand,
            Template = BuildTemplate(Brushes.DeepSkyBlue)
        };
    }

    private static ControlTemplate BuildTemplate(Brush color)
    {
        var t = new ControlTemplate(typeof(Thumb));
        var f = new FrameworkElementFactory(typeof(Border));

        f.SetValue(Border.BackgroundProperty, color);
        f.SetValue(Border.BorderBrushProperty, Brushes.White);
        f.SetValue(Border.BorderThicknessProperty, new Thickness(1));
        f.SetValue(Border.CornerRadiusProperty, new CornerRadius(2));

        t.VisualTree = f;
        return t;
    }
}

ファイル名:MainWindow.xaml

<Window x:Class="AdornerDesignerDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Adorner Designer Demo" Width="1000" Height="700">
    <DockPanel>
        <!-- Toolbar -->
        <ToolBar DockPanel.Dock="Top">
            <Button Width="140" Margin="2" Click="AddRectangle_Click">四角を追加</Button>
            <Button Width="140" Margin="2" Click="AddButton_Click">ボタンを追加</Button>
            <Button Width="140" Margin="2" Click="AddText_Click">テキストを追加</Button>
            <Separator/>
            <Button Width="140" Margin="2" Click="DeleteSelected_Click">選択を削除</Button>
            <Separator/>
            <TextBlock Margin="8,0,4,0" VerticalAlignment="Center">ズーム</TextBlock>
            <Slider x:Name="ZoomSlider" Width="160" Minimum="0.25" Maximum="2.0" Value="1.0"
                    TickFrequency="0.25" IsSnapToTickEnabled="True"
                    ValueChanged="ZoomSlider_ValueChanged"/>
            <TextBlock x:Name="ZoomText" Margin="6,0,0,0" VerticalAlignment="Center"/>
        </ToolBar>

        <!-- Surface -->
        <Border BorderBrush="#777" BorderThickness="1" Margin="8" Background="#111">
            <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
                <!-- AdornerLayer を確実に作るために、ScrollViewer配下に素直なCanvas -->
                <Canvas x:Name="DesignSurface"
                        Width="2000" Height="1200"
                        Background="#202020"
                        Focusable="True"
                        MouseLeftButtonDown="DesignSurface_MouseLeftButtonDown"
                        KeyDown="DesignSurface_KeyDown">
                    <Canvas.RenderTransform>
                        <ScaleTransform x:Name="SurfaceScale" ScaleX="1" ScaleY="1"/>
                    </Canvas.RenderTransform>
                </Canvas>
            </ScrollViewer>
        </Border>
    </DockPanel>
</Window>

実行例

使い方

  • ツールバーの「四角を追加」「ボタンを追加」「テキストを追加」ボタンでコントロールが追加されます。
  • コントロールをクリックするとガイド(SelectionAdorner)が表示され選択状態になります。
  • ツールバーの「選択を削除」ボタンで選択中コントロールが削除されます。
  • 各辺のオレンジ色四角をドラックすると選択中のコントロールが拡大・縮小されます。
  • 上辺のオレンジ色四角は特別で、若干下にマウスカーソルをセットすると、十字矢印アイコンに変化しコントロールの移動することが出来ます。
  • 水色の四角をドラックするとコントロールが回転します。
  • コントロール外をクリックすると、コントロールの選択が解除されます。

※操作中コントロールが消えるバグあり

コメント