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

コンピュータ

画像を表示するImageコントロールをベースにスクロール、Ctrl+マウスホイールによるズーム、マウスドラックによるパン機能を付与した、ユーザーコントロールのサンプルコードになります。

ソースコード

・ユーザーコントロール本体

ファイル名:ZoomImageControl.cs

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

namespace ZoomImageUC;
public class ZoomImageControl : UserControl
{
    private readonly ScrollViewer _scroll;
    private readonly Border _border;
    private readonly Image _image;
    private readonly ScaleTransform _scale;

    // ----- 依存プロパティ -----
    public static readonly DependencyProperty SourceProperty =
        DependencyProperty.Register(
            nameof(Source),
            typeof(ImageSource),
            typeof(ZoomImageControl),
            new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));

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

    // ----- ズーム設定 -----
    public double WheelZoomStep { get; set; } = 1.1;      // 1ノッチで±10%
    public double MinZoom { get; set; } = 0.05;
    public double MaxZoom { get; set; } = 20.0;

    /// <summary>この修飾キーが押されているときだけホイールでズーム(既定: Ctrl)。None にすると常時ズーム。</summary>
    public ModifierKeys ZoomModifierKey { get; set; } = ModifierKeys.Control;

    public double Zoom
    {
        get => _scale.ScaleX;
        set
        {
            var clamped = Math.Max(MinZoom, Math.Min(MaxZoom, value));
            _scale.ScaleX = clamped;
            _scale.ScaleY = clamped;
        }
    }

    // ----- ドラッグ(パン)状態 -----
    private bool _isDragging;
    private Point _dragStartPos;
    private Point _dragStartOffset;

    public ZoomImageControl()
    {
        _scroll = new ScrollViewer
        {
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility   = ScrollBarVisibility.Auto,
            CanContentScroll              = false,
            PanningMode                   = PanningMode.None,
            Focusable                     = true
        };

        _border = new Border
        {
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment   = VerticalAlignment.Center
        };
        _border.SetBinding(Border.BackgroundProperty,
            new System.Windows.Data.Binding(nameof(Background)) { Source = this });

        _image = new Image
        {
            Stretch = Stretch.None,
            SnapsToDevicePixels = true
        };
        _image.SetBinding(Image.SourceProperty,
            new System.Windows.Data.Binding(nameof(Source)) { Source = this });

        _scale = new ScaleTransform(1.0, 1.0);
        _image.LayoutTransform = _scale;

        _border.Child   = _image;
        _scroll.Content = _border;
        Content         = _scroll;

        if (Background == null) Background = Brushes.Transparent;

        // 入力イベント
        PreviewMouseWheel        += OnPreviewMouseWheel;     // 修飾キーありのときだけズーム
        _image.MouseLeftButtonDown += OnMouseLeftButtonDown; // ドラッグ or ダブルクリック
        _image.MouseLeftButtonUp   += OnMouseLeftButtonUp;
        _image.MouseMove           += OnMouseMove;
        _image.MouseLeave          += (_, __) => { if (_isDragging) EndDrag(); };
    }

    // ===== ズーム =====
    private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        // 修飾キー判定(None の場合は常時ズーム)
        if (!IsZoomModifierSatisfied()) return;

        if (_image.Source == null) return;

        double factor = e.Delta > 0 ? WheelZoomStep : (1.0 / WheelZoomStep);
        ZoomAroundMouse(factor, e);
        e.Handled = true; // ScrollViewer の通常スクロールを抑止
    }

    private bool IsZoomModifierSatisfied()
    {
        if (ZoomModifierKey == ModifierKeys.None) return true;
        return (Keyboard.Modifiers & ZoomModifierKey) == ZoomModifierKey;
    }

    private void ZoomAroundMouse(double factor, MouseWheelEventArgs e)
    {
        // ScrollViewer 内のマウス座標(表示座標)
        Point mouseInScroll = e.GetPosition(_scroll);

        // 画像要素内の座標(拡大後座標)
        Point mouseOnImage = e.GetPosition(_image);

        double oldScale = Zoom;
        double newScale = Math.Max(MinZoom, Math.Min(MaxZoom, oldScale * factor));
        if (Math.Abs(newScale - oldScale) < 1e-6) return;

        // 論理座標(拡大前の座標系)
        double logicalX = mouseOnImage.X / oldScale;
        double logicalY = mouseOnImage.Y / oldScale;

        Zoom = newScale;

        _scroll.UpdateLayout();

        double targetOffsetX = logicalX * newScale - mouseInScroll.X;
        double targetOffsetY = logicalY * newScale - mouseInScroll.Y;

        var maxX = Math.Max(0.0, _scroll.ExtentWidth  - _scroll.ViewportWidth);
        var maxY = Math.Max(0.0, _scroll.ExtentHeight - _scroll.ViewportHeight);

        _scroll.ScrollToHorizontalOffset(Math.Max(0.0, Math.Min(maxX, targetOffsetX)));
        _scroll.ScrollToVerticalOffset  (Math.Max(0.0, Math.Min(maxY, targetOffsetY)));
    }

    // ===== ドラッグ(パン) =====
    private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (_image.Source == null) return;

        // ダブルクリック相当
        if (e.ClickCount == 2)
        {
            ResetZoomAndCenter();
            e.Handled = true;
            return;
        }

        _isDragging = true;
        _dragStartPos    = e.GetPosition(_scroll);
        _dragStartOffset = new Point(_scroll.HorizontalOffset, _scroll.VerticalOffset);

        _image.CaptureMouse();
        _image.Cursor = Cursors.SizeAll;
        e.Handled = true;
    }

    private void OnMouseMove(object sender, MouseEventArgs e)
    {
        if (!_isDragging) return;

        Point current = e.GetPosition(_scroll);
        Vector delta = current - _dragStartPos;

        double targetX = _dragStartOffset.X - delta.X;
        double targetY = _dragStartOffset.Y - delta.Y;

        var maxX = Math.Max(0.0, _scroll.ExtentWidth  - _scroll.ViewportWidth);
        var maxY = Math.Max(0.0, _scroll.ExtentHeight - _scroll.ViewportHeight);

        _scroll.ScrollToHorizontalOffset(Math.Max(0.0, Math.Min(maxX, targetX)));
        _scroll.ScrollToVerticalOffset  (Math.Max(0.0, Math.Min(maxY, targetY)));
    }

    private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        if (_isDragging) { EndDrag(); e.Handled = true; }
    }

    private void EndDrag()
    {
        _isDragging = false;
        _image.ReleaseMouseCapture();
        _image.Cursor = Cursors.Arrow;
    }

    // ===== 便利操作 =====
    public void ResetZoom() => Zoom = 1.0;

    public void ResetZoomAndCenter()
    {
        Zoom = 1.0;
        _scroll.UpdateLayout();

        // 画像(拡大後)の実表示サイズ
        double w = _image.ActualWidth;
        double h = _image.ActualHeight;

        // ビューポートとの差から中央寄せ(はみ出すときはスクロール中央へ)
        double targetX = Math.Max(0.0, (w - _scroll.ViewportWidth)  / 2.0);
        double targetY = Math.Max(0.0, (h - _scroll.ViewportHeight) / 2.0);

        _scroll.ScrollToHorizontalOffset(targetX);
        _scroll.ScrollToVerticalOffset  (targetY);
    }

    public void ZoomIn (double factor = 1.1) => Zoom = Zoom * factor;
    public void ZoomOut(double factor = 1.1) => Zoom = Zoom / factor;

    // 内部要素にアクセスしたい場合
    public Image        InnerImage        => _image;
    public ScrollViewer InnerScrollViewer => _scroll;
}

・使う側のコード

ファイル名:MainWindow.xaml

<Window x:Class="ZoomImageUC.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ZoomImageUC"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <local:ZoomImageControl
            Background="Black"
            Source="C:\Users\karet\Pictures\IMG_20251009_180913471_MFNR.jpg"/>
    </Grid>
</Window>

感想

ユーザーコントロールZoomImageControlは一つのクラスにまとまっています。このクラスを別のアプリ(プロジェクト)に追加するだけで、ズーム機能を持ったImageコントロールを使うことが出来ます。
動作確認した感じ、ホイールによる挙動が怪しい部分がありますが、とりあえずは動作を確認。

コメント