XAMLで始めるWPF入門:カスタムコントロールで作るズーム・パン機能付きImageコントロール

コンピュータ

マウスホイールで拡大・縮小機能、ドラックで移動機能を付与したImageコントロールを作成しました。カスタムコントロールですので他プログラムで再利用しやすいかと思います。

ソースコード

ファイル名:Controls\ScrollableImage.cs
・カスタムコントロールの本体

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

namespace ScrollableImageSample;

public class ScrollableImage : Control
{
    // コンストラクタ
    static ScrollableImage()
    {
        // Generic.xaml の既定テンプレートをこの型に自動適用
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(ScrollableImage),
            new FrameworkPropertyMetadata(typeof(ScrollableImage)));
    }

    // --- 画像ソース ---
    public static readonly DependencyProperty SourceProperty =
        DependencyProperty.Register(nameof(Source), typeof(ImageSource),
            typeof(ScrollableImage),
            new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsMeasure));

    public ImageSource? Source
    {
        get => (ImageSource?)GetValue(SourceProperty);
        set => SetValue(SourceProperty, value);
    }

    // --- ズーム ---
    public static readonly DependencyProperty ZoomProperty =
        DependencyProperty.Register(nameof(Zoom), typeof(double),
            typeof(ScrollableImage),
            new FrameworkPropertyMetadata(1.0, OnZoomChanged, CoerceZoom));
            // ⬆️OnZoomChanged, CoerceZoom後述

    public double Zoom
    {
        get => (double)GetValue(ZoomProperty);
        set => SetValue(ZoomProperty, value);
    }

    // --- ズーム最小 ---
    public static readonly DependencyProperty MinZoomProperty =
        DependencyProperty.Register(nameof(MinZoom), typeof(double),
            typeof(ScrollableImage), new PropertyMetadata(0.1));

    public double MinZoom
    {
        get => (double)GetValue(MinZoomProperty);
        set => SetValue(MinZoomProperty, value);
    }

    // --- ズーム最大 ---
    public static readonly DependencyProperty MaxZoomProperty =
        DependencyProperty.Register(nameof(MaxZoom), typeof(double),
            typeof(ScrollableImage), new PropertyMetadata(8.0));

    public double MaxZoom
    {
        get => (double)GetValue(MaxZoomProperty);
        set => SetValue(MaxZoomProperty, value);
    }

    // Zoom の CoerceValueCallback:指定値を MinZoom~MaxZoom に丸めて返す(コールバック)
    private static object CoerceZoom(DependencyObject d, object baseValue)
    {
        var c = (ScrollableImage)d;
        var z = (double)baseValue;
        return Math.Clamp(z, c.MinZoom, c.MaxZoom);
    }

    // Zoom 依存関係プロパティの値変更時に呼ばれる(コールバック)
    private static void OnZoomChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var c = (ScrollableImage)d;
        c.ApplyZoomToTransform((double)e.NewValue);

        // ビューポート中心を基準に再配置
        if (c._scroll is null) return;
        double scale = Math.Max((double)e.NewValue, 1e-9) / Math.Max((double)e.OldValue, 1e-9);
        var centerX = c._scroll.HorizontalOffset + c._scroll.ViewportWidth / 2;
        var centerY = c._scroll.VerticalOffset + c._scroll.ViewportHeight / 2;
        c._scroll.ScrollToHorizontalOffset(centerX * scale - c._scroll.ViewportWidth / 2);
        c._scroll.ScrollToVerticalOffset(centerY * scale - c._scroll.ViewportHeight / 2);
    }

    private ScrollViewer? _scroll;
    private ScaleTransform? _scale;

    // Generic.xaml のコントロール テンプレート適用後に呼ばれる(PART取得やイベント接続はここで)(イベント)
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        _scroll = GetTemplateChild("PART_ScrollViewer") as ScrollViewer;
        _scale  = GetTemplateChild("PART_Scale") as ScaleTransform;

        ApplyZoomToTransform(Zoom);

        // イベントは最小限:ホイール拡縮+左ドラッグパン
        AddHandler(UIElement.PreviewMouseWheelEvent, new MouseWheelEventHandler(OnWheelZoom), true);
        AddHandler(UIElement.PreviewMouseLeftButtonDownEvent, new MouseButtonEventHandler(OnDragStart), true);
        AddHandler(UIElement.PreviewMouseMoveEvent, new MouseEventHandler(OnDragMove), true);
        AddHandler(UIElement.PreviewMouseLeftButtonUpEvent, new MouseButtonEventHandler(OnDragEnd), true);
        AddHandler(UIElement.LostMouseCaptureEvent, new MouseEventHandler((s, e) => { if (_dragging) EndDrag(); }), true);
    }

    // ScaleTransform に現在の Zoom 値を適用する(ヘルパー)
    private void ApplyZoomToTransform(double z)
    {
        if (_scale is null) return;
        _scale.ScaleX = z;
        _scale.ScaleY = z;
    }

    // ---- ホイールでズーム(ビューポート中心基準)----(イベント)
    private void OnWheelZoom(object? sender, MouseWheelEventArgs e)
    {
        var step = (Keyboard.Modifiers & ModifierKeys.Control) != 0 ? 0.1 : 0.2;
        var newZoom = Zoom + (e.Delta > 0 ? step : -step);
        Zoom = (double)CoerceZoom(this, newZoom);
        e.Handled = true; // 既定のスクロールを抑止
    }

    // ---- 左ドラッグでパン----
    private bool _dragging;
    private Point _startPos;
    private double _startH, _startV;

    // 左ボタンドラッグ開始時にスクロール位置を記録しマウスキャプチャを行う(イベント)
    private void OnDragStart(object? s, MouseButtonEventArgs e)
    {
        if (_scroll is null) return;
        _dragging = true;
        _startPos = e.GetPosition(this);
        _startH = _scroll.HorizontalOffset;
        _startV = _scroll.VerticalOffset;
        Cursor = Cursors.Hand;
        CaptureMouse();
        e.Handled = true;
    }

    // ドラッグ移動中にマウスの移動量に応じてスクロール位置を更新する(イベント)
    private void OnDragMove(object? s, MouseEventArgs e)
    {
        if (!_dragging || _scroll is null) return;
        var p = e.GetPosition(this);
        _scroll.ScrollToHorizontalOffset(_startH - (p.X - _startPos.X));
        _scroll.ScrollToVerticalOffset(_startV - (p.Y - _startPos.Y));
        e.Handled = true;
    }

    // 左ボタンドラッグ終了時にドラッグ状態を解除しマウスキャプチャを解放する(イベント)
    private void OnDragEnd(object? s, MouseButtonEventArgs e)
    {
        if (_dragging) { EndDrag(); e.Handled = true; }
    }

    // ドラッグ操作を終了しカーソルとマウスキャプチャを元に戻す(ヘルパー)
    private void EndDrag()
    {
        _dragging = false;
        Cursor = Cursors.Arrow;
        if (IsMouseCaptured) ReleaseMouseCapture();
    }
}

ファイル名:Themes\Generic.xaml
・コントロールテンプレート

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:ScrollableImageSample">

  <Style TargetType="{x:Type local:ScrollableImage}">
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:ScrollableImage}">
          <ScrollViewer x:Name="PART_ScrollViewer"
                        HorizontalScrollBarVisibility="Auto"
                        VerticalScrollBarVisibility="Auto"
                        CanContentScroll="False"
                        PanningMode="None">
            <Border Background="{TemplateBinding Background}"
                    HorizontalAlignment="Center"
                    VerticalAlignment="Center">
              <Image x:Name="PART_Image"
                     Source="{TemplateBinding Source}"
                     Stretch="None"
                     SnapsToDevicePixels="True">
                <!-- Binding は使わず、コードから倍率を入れる -->
                <Image.LayoutTransform>
                  <ScaleTransform x:Name="PART_Scale" ScaleX="1" ScaleY="1"/>
                </Image.LayoutTransform>
              </Image>
            </Border>
          </ScrollViewer>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>
</ResourceDictionary>

ファイル名:MainWindow.xaml
・使う側のコード

<Window x:Class="ScrollableImageSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:ScrollableImageSample"
        Title="ScrollableImage Demo" Width="900" Height="600">
  <Grid>
    <local:ScrollableImage
        Source="J:\csharp\wpf2\ScrollableImageSample\color-sampl2.png"
        MinZoom="0.1"
        MaxZoom="8"
        Zoom="1.0"
        Background="#111"/>
  </Grid>
</Window>

実行イメージ

コメント