143

I want to create a simple image viewer in WPF that will enable the user to:

  • Pan (by mouse dragging the image).
  • Zoom (with a slider).
  • Show overlays (rectangle selection for example).
  • Show original image (with scroll bars if needed).

Can you explain how to do it?

I didn't find a good sample on the web. Should I use ViewBox? Or ImageBrush? Do I need ScrollViewer?

ASh
  • 34,632
  • 9
  • 60
  • 82
Yuval Peled
  • 4,988
  • 8
  • 30
  • 36
  • To get a professional Zoom Control for WPF check out the [ZoomPanel](http://www.wpf-graphics.com/ZoomPanel.aspx). It is not free, but is very easy to use and has many features - animated zooming and panning, support for ScrollViewer, mouse wheel support, included ZoomController (with move, zoom in, zoom out, rectangle zoom, reset buttons). It also comes with many code samples. – Andrej Benedik Apr 02 '10 at 18:20
  • 1
    Good find. Free to try, and they want $69/computer for a license if you intend to build software with it. It's a DLL to use, so they couldn't stop you, but it is where, if you're building it commercially for a client, especially one requiring any third-party utility to be declared & individually licensed, you would have to pay the development fee. In the EULA it didn't say it was on a "per application" basis, though, so as soon as you registered your purchase, it would then be "free" for all applications you created, and could copy your paid license file in with it to represent the purchase. – vapcguy Mar 21 '17 at 21:19
  • I wrote an article on codeproject.com on the implementation of a zoom and pan control for WPF. http://www.codeproject.com/KB/WPF/zoomandpancontrol.aspx – Ashley Davis Aug 11 '10 at 14:34

12 Answers12

229

After using samples from this question I've made complete version of pan & zoom app with proper zooming relative to mouse pointer. All pan & zoom code has been moved to separate class called ZoomBorder.

ZoomBorder.cs

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

namespace PanAndZoom
{
  public class ZoomBorder : Border
  {
    private UIElement child = null;
    private Point origin;
    private Point start;

    private TranslateTransform GetTranslateTransform(UIElement element)
    {
      return (TranslateTransform)((TransformGroup)element.RenderTransform)
        .Children.First(tr => tr is TranslateTransform);
    }

    private ScaleTransform GetScaleTransform(UIElement element)
    {
      return (ScaleTransform)((TransformGroup)element.RenderTransform)
        .Children.First(tr => tr is ScaleTransform);
    }

    public override UIElement Child
    {
      get { return base.Child; }
      set
      {
        if (value != null && value != this.Child)
          this.Initialize(value);
        base.Child = value;
      }
    }

    public void Initialize(UIElement element)
    {
      this.child = element;
      if (child != null)
      {
        TransformGroup group = new TransformGroup();
        ScaleTransform st = new ScaleTransform();
        group.Children.Add(st);
        TranslateTransform tt = new TranslateTransform();
        group.Children.Add(tt);
        child.RenderTransform = group;
        child.RenderTransformOrigin = new Point(0.0, 0.0);
        this.MouseWheel += child_MouseWheel;
        this.MouseLeftButtonDown += child_MouseLeftButtonDown;
        this.MouseLeftButtonUp += child_MouseLeftButtonUp;
        this.MouseMove += child_MouseMove;
        this.PreviewMouseRightButtonDown += new MouseButtonEventHandler(
          child_PreviewMouseRightButtonDown);
      }
    }

    public void Reset()
    {
      if (child != null)
      {
        // reset zoom
        var st = GetScaleTransform(child);
        st.ScaleX = 1.0;
        st.ScaleY = 1.0;

        // reset pan
        var tt = GetTranslateTransform(child);
        tt.X = 0.0;
        tt.Y = 0.0;
      }
    }

    #region Child Events

        private void child_MouseWheel(object sender, MouseWheelEventArgs e)
        {
            if (child != null)
            {
                var st = GetScaleTransform(child);
                var tt = GetTranslateTransform(child);

                double zoom = e.Delta > 0 ? .2 : -.2;
                if (!(e.Delta > 0) && (st.ScaleX < .4 || st.ScaleY < .4))
                    return;

                Point relative = e.GetPosition(child);
                double absoluteX;
                double absoluteY;

                absoluteX = relative.X * st.ScaleX + tt.X;
                absoluteY = relative.Y * st.ScaleY + tt.Y;

                st.ScaleX += zoom;
                st.ScaleY += zoom;

                tt.X = absoluteX - relative.X * st.ScaleX;
                tt.Y = absoluteY - relative.Y * st.ScaleY;
            }
        }

        private void child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            if (child != null)
            {
                var tt = GetTranslateTransform(child);
                start = e.GetPosition(this);
                origin = new Point(tt.X, tt.Y);
                this.Cursor = Cursors.Hand;
                child.CaptureMouse();
            }
        }

        private void child_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            if (child != null)
            {
                child.ReleaseMouseCapture();
                this.Cursor = Cursors.Arrow;
            }
        }

        void child_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
        {
            this.Reset();
        }

        private void child_MouseMove(object sender, MouseEventArgs e)
        {
            if (child != null)
            {
                if (child.IsMouseCaptured)
                {
                    var tt = GetTranslateTransform(child);
                    Vector v = start - e.GetPosition(this);
                    tt.X = origin.X - v.X;
                    tt.Y = origin.Y - v.Y;
                }
            }
        }

        #endregion
    }
}

MainWindow.xaml

<Window x:Class="PanAndZoom.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:PanAndZoom"
        Title="PanAndZoom" Height="600" Width="900" WindowStartupLocation="CenterScreen">
    <Grid>
        <local:ZoomBorder x:Name="border" ClipToBounds="True" Background="Gray">
            <Image Source="image.jpg"/>
        </local:ZoomBorder>
    </Grid>
</Window>

MainWindow.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace PanAndZoom
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }
    }
}
Peter Boone
  • 1,193
  • 1
  • 12
  • 20
Wiesław Šoltés
  • 3,114
  • 1
  • 21
  • 15
  • 10
    Before comments get blocked for "Nice Job!" or "Great Work" I just want to say Nice Job and Great Work. This is a WPF gem. It blows the wpf ext zoombox out of the water. – Jesse Seger May 12 '14 at 18:54
  • 1
    AWESOME. I didn't though about such an implementation but it is really nice! Thank you so much! – Noel Widmer Sep 15 '14 at 12:09
  • Is there a way to zoom in and pan without needing the cursor to be on the image itself? Like if I grabbed the white space around the image, is there a way to do the same results? Right now the events only hit if you're over the image. – Bojo May 11 '15 at 14:30
  • 6
    great answer! I have added a slight correction to the zoom factor, so it does not zoom "slower" `double zoomCorrected = zoom*st.ScaleX; st.ScaleX += zoomCorrected; st.ScaleY += zoomCorrected;` – DELUXEnized Jun 09 '15 at 16:24
  • Hey, i implemented your code and it was awesome. Though, i wanted to reset the size from a push of a button, but calling the Reset method didn't work. I thing condition isn't satisfied. what should be done? – Skaranjit Feb 02 '16 at 10:42
  • 2
    @Skaranjit No idea, the Reset() works in provided sample code. Did you call border.Reset() from MainWindow? You can try the demo here: https://github.com/wieslawsoltes/PanAndZoomDemo – Wiesław Šoltés Feb 03 '16 at 20:15
  • 2
    This has been a great solution! One thing that I need though is I have this wrapped in a scrollviewer. When zooming in, the scroll bars should become visible and the thumbwheel should size correctly.. it does not. And After googling this issue, it is not a trivial problem. Does anyone have a solution that works with this zoomborder control? – AshbyEngineer Jun 23 '16 at 00:05
  • 1
    This solution is amazing. I have posted a new question about adding the capability of returning the mouse position in the original image coordinate system here: https://stackoverflow.com/questions/44585345/how-would-i-add-an-eyedropper-tool-to-the-excellent-wpf-zoomborder-cs-solution. Would anyone be able to help? – Lordalcol Jun 16 '17 at 09:14
  • 1
    Great! I have implemented your code and it works well but the ZoomBorder control appears over the rest of the controls when I do MouseWheel event (Zoom in). My ZoomBorder is contained in a Grid. I would like to maintein the ZoomBorder control inside the Grid. How can I do that? – Jafuentes Sep 20 '18 at 10:25
  • 2
    @Jafuentes: Make usre you set ClipToBounds="True" – Rodney Richardson Apr 16 '19 at 15:06
  • I am wondering if anybody can give me a hint on how to use this ZoomBorder within a ScrollViewer element. I want to show scrollbars when the image is moved. – Franz Gsell Jun 03 '19 at 14:14
  • 1
    This works (except that I experienced one weird jumping of location), but the zoom is not as smooth as Microsoft's Photos app (Windows 10). Can that level of zoom-smoothness be achieved with WPF? If you open the Photos app and compare its zoo to this code, you will see the vast difference in smoothness. – Damn Vegetables Nov 18 '19 at 23:06
  • 1
    Brilliant! When using in a list of images though, the list scrolls down at the same time as the image zooms. How can this be solved? – aryeh Apr 18 '21 at 14:52
  • I struggled a little with the images not showing due to me, first forgetting to add the assets, and secondly my failure to do a complete rebuild *d'oh*. Now it's just pure joy and I will spent some time just zoompanning through WFC [sic] generated images :-) – Xan-Kun Clark-Davis Apr 16 '22 at 20:18
  • Thanks a lot for this ! I added those lines in `Initialize` to show to user it is clickable (with the mouse on hover): `this.MouseEnter += (s, e) => Mouse.OverrideCursor = Cursors.Hand; this.MouseLeave += (s, e) => Mouse.OverrideCursor = Cursors.Arrow;` – Quentin CG Mar 22 '23 at 15:23
120

The way I solved this problem was to place the image within a Border with it's ClipToBounds property set to True. The RenderTransformOrigin on the image is then set to 0.5,0.5 so the image will start zooming on the center of the image. The RenderTransform is also set to a TransformGroup containing a ScaleTransform and a TranslateTransform.

I then handled the MouseWheel event on the image to implement zooming

private void image_MouseWheel(object sender, MouseWheelEventArgs e)
{
    var st = (ScaleTransform)image.RenderTransform;
    double zoom = e.Delta > 0 ? .2 : -.2;
    st.ScaleX += zoom;
    st.ScaleY += zoom;
}

To handle the panning the first thing I did was to handle the MouseLeftButtonDown event on the image, to capture the mouse and to record it's location, I also store the current value of the TranslateTransform, this what is updated to implement panning.

Point start;
Point origin;
private void image_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    image.CaptureMouse();
    var tt = (TranslateTransform)((TransformGroup)image.RenderTransform)
        .Children.First(tr => tr is TranslateTransform);
    start = e.GetPosition(border);
    origin = new Point(tt.X, tt.Y);
}

Then I handled the MouseMove event to update the TranslateTransform.

private void image_MouseMove(object sender, MouseEventArgs e)
{
    if (image.IsMouseCaptured)
    {
        var tt = (TranslateTransform)((TransformGroup)image.RenderTransform)
            .Children.First(tr => tr is TranslateTransform);
        Vector v = start - e.GetPosition(border);
        tt.X = origin.X - v.X;
        tt.Y = origin.Y - v.Y;
    }
}

Finally don't forget to release the mouse capture.

private void image_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
    image.ReleaseMouseCapture();
}

As for the selection handles for resizing this can be accomplished using an adorner, check out this article for more information.

Ian Oakes
  • 10,153
  • 6
  • 36
  • 47
  • 10
    One observation though, calling CaptureMouse in image_MouseLeftButtonDown will result in a call to image_MouseMove where origin is not initialized yet - in the above code, it will be zero by pure chance, but if the origin is other than (0,0), the image will experience a short jump. Therefore, i think it's better to call image.CaptureMouse() at the end of the image_MouseLeftButtonDown to fix this issue. – Andrei Pana Nov 30 '10 at 16:42
  • 2
    Two Things. 1) There is a bug with image_MouseWheel, you have to get the ScaleTransform in a similar manner you get TranslateTransform. That is, Cast it to a TransformGroup then select and cast the appropriate Child. 2) If your movement is Jittery remember that you can't use the image to get your mouse position (since its dynamic), you have to use something static. In this example, a border is used. – Dave Aug 27 '11 at 20:12
49

The answer was posted above but wasn't complete. here is the completed version:

XAML

<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MapTest.Window1"
x:Name="Window"
Title="Window1"
Width="1950" Height="1546" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:Controls="clr-namespace:WPFExtensions.Controls;assembly=WPFExtensions" mc:Ignorable="d" Background="#FF000000">

<Grid x:Name="LayoutRoot">
    <Grid.RowDefinitions>
        <RowDefinition Height="52.92"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>

    <Border Grid.Row="1" Name="border">
        <Image Name="image" Source="map3-2.png" Opacity="1" RenderTransformOrigin="0.5,0.5"  />
    </Border>

</Grid>

Code Behind

using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;

namespace MapTest
{
    public partial class Window1 : Window
    {
        private Point origin;
        private Point start;

        public Window1()
        {
            InitializeComponent();

            TransformGroup group = new TransformGroup();

            ScaleTransform xform = new ScaleTransform();
            group.Children.Add(xform);

            TranslateTransform tt = new TranslateTransform();
            group.Children.Add(tt);

            image.RenderTransform = group;

            image.MouseWheel += image_MouseWheel;
            image.MouseLeftButtonDown += image_MouseLeftButtonDown;
            image.MouseLeftButtonUp += image_MouseLeftButtonUp;
            image.MouseMove += image_MouseMove;
        }

        private void image_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            image.ReleaseMouseCapture();
        }

        private void image_MouseMove(object sender, MouseEventArgs e)
        {
            if (!image.IsMouseCaptured) return;

            var tt = (TranslateTransform) ((TransformGroup) image.RenderTransform).Children.First(tr => tr is TranslateTransform);
            Vector v = start - e.GetPosition(border);
            tt.X = origin.X - v.X;
            tt.Y = origin.Y - v.Y;
        }

        private void image_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            image.CaptureMouse();
            var tt = (TranslateTransform) ((TransformGroup) image.RenderTransform).Children.First(tr => tr is TranslateTransform);
            start = e.GetPosition(border);
            origin = new Point(tt.X, tt.Y);
        }

        private void image_MouseWheel(object sender, MouseWheelEventArgs e)
        {
            TransformGroup transformGroup = (TransformGroup) image.RenderTransform;
            ScaleTransform transform = (ScaleTransform) transformGroup.Children[0];

            double zoom = e.Delta > 0 ? .2 : -.2;
            transform.ScaleX += zoom;
            transform.ScaleY += zoom;
        }
    }
}

I have some source code demonstrating this Jot the sticky note app.

Kelly
  • 6,992
  • 12
  • 59
  • 76
  • 1
    Any suggestions on how to make this usable in Silverlight 3? I have problems with Vector and subtracting one Point from another... Thanks. – Number8 Feb 01 '10 at 19:13
  • @Number8 Posted an implementation that works in Silverlight 3 for you below :) – Henry C Aug 23 '10 at 07:06
  • 4
    a small drawback - the image grows **with** the border, and not **inside** the border – itsho Jan 11 '12 at 18:09
  • can you guys suggest something how to implement the same thing in windows 8 metro style app..im working on c#,xaml on windows8 – raj May 07 '13 at 13:35
  • very nice code. using it and works well. just a question. how would you limit a zoom out? like what size would you configure in cs? we don't want our users to get frustrated and zoom too far out on the image, that it becomes small. – Nicholas Aysen Feb 18 '15 at 13:31
  • 1
    In image_MouseWheel you can test the transform.ScaleX and ScaleY values and if those values + zoom > your limit, don't apply the += zoom lines. – Kelly Feb 18 '15 at 13:44
  • Pls correct your link to `Jot the sticky note app.` – NoWar Oct 20 '21 at 04:19
  • Before I closed the website I added it to GitHub. You can find it here: https://github.com/kelias/Jot I updated the post and added the GitHub link instead of my own website link. – Kelly Jan 11 '22 at 21:49
11
  • Pan: Put the image inside of a Canvas. Implement Mouse Up, Down, and Move events to move the Canvas.Top, Canvas.Left properties. When down, you mark a isDraggingFlag to true, when up you set the flag to false. On move, you check if the flag is set, if it is you offset the Canvas.Top and Canvas.Left properties on the image within the canvas.
  • Zoom: Bind the slider to the Scale Transform of the Canvas
  • Show overlays: add additional canvas's with no background ontop the canvas containing the image.
  • show original image: image control inside of a ViewBox
markti
  • 2,783
  • 1
  • 23
  • 30
9

Try this Zoom Control: http://wpfextensions.codeplex.com

usage of the control is very simple, reference to the wpfextensions assembly than:

<wpfext:ZoomControl>
    <Image Source="..."/>
</wpfext:ZoomControl>

Scrollbars not supported at this moment. (It will be in the next release which will be available in one or two week).

Palesz
  • 2,104
  • 18
  • 20
  • Yup, enjoying that. The rest of thee library is pretty trivial though. – EightyOne Unite Nov 05 '09 at 12:45
  • There doesn't seem to be direct support for 'Show overlays (rectangle selection for example)' though, but for zoom/panning behavior, it is a great control. – jsirr13 Oct 28 '13 at 22:08
4

@Anothen and @Number8 - The Vector class is not available in Silverlight, so to make it work we just need to keep a record of the last position sighted the last time the MouseMove event was called, and compare the two points to find the difference; then adjust the transform.

XAML:

    <Border Name="viewboxBackground" Background="Black">
            <Viewbox Name="viewboxMain">
                <!--contents go here-->
            </Viewbox>
    </Border>  

Code-behind:

    public Point _mouseClickPos;
    public bool bMoving;


    public MainPage()
    {
        InitializeComponent();
        viewboxMain.RenderTransform = new CompositeTransform();
    }

    void MouseMoveHandler(object sender, MouseEventArgs e)
    {

        if (bMoving)
        {
            //get current transform
            CompositeTransform transform = viewboxMain.RenderTransform as CompositeTransform;

            Point currentPos = e.GetPosition(viewboxBackground);
            transform.TranslateX += (currentPos.X - _mouseClickPos.X) ;
            transform.TranslateY += (currentPos.Y - _mouseClickPos.Y) ;

            viewboxMain.RenderTransform = transform;

            _mouseClickPos = currentPos;
        }            
    }

    void MouseClickHandler(object sender, MouseButtonEventArgs e)
    {
        _mouseClickPos = e.GetPosition(viewboxBackground);
        bMoving = true;
    }

    void MouseReleaseHandler(object sender, MouseButtonEventArgs e)
    {
        bMoving = false;
    }

Also note that you don't need a TransformGroup or collection to implement pan and zoom; instead, a CompositeTransform will do the trick with less hassle.

I'm pretty sure this is really inefficient in terms of resource usage, but at least it works :)

Henry C
  • 4,781
  • 4
  • 43
  • 83
3

To zoom relative to the mouse position, all you need is:

var position = e.GetPosition(image1);
image1.RenderTransformOrigin = new Point(position.X / image1.ActualWidth, position.Y / image1.ActualHeight);
Patrick
  • 31
  • 1
3

I also tried this answer but was not entirely happy with the result. I kept googling around and finally found a Nuget Package that helped me to manage the result I wanted, anno 2021. I would like to share it with the former developers of Stack Overflow.

I used this Nuget Package Gu.WPF.Geometry found via this Github Repository. All credits for develoment should go to Johan Larsson, the owner of this package.

How I used it? I wanted to have the commands as buttons below the zoombox, as shown here in MachineLayoutControl.xaml .

<UserControl
   x:Class="MyLib.MachineLayoutControl"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   xmlns:csmachinelayoutdrawlib="clr-namespace:CSMachineLayoutDrawLib"
   xmlns:effects="http://gu.se/Geometry">
   <UserControl.Resources>
       <ResourceDictionary Source="Resources/ResourceDictionaries/AllResourceDictionariesCombined.xaml" />
   </UserControl.Resources>

   <Grid Margin="0">
       <Grid.RowDefinitions>
           <RowDefinition Height="*" />
           <RowDefinition Height="Auto" />
       </Grid.RowDefinitions>

       <Border
           Grid.Row="0"
           Margin="0,0"
           Padding="0"
           BorderThickness="1"
           Style="{StaticResource Border_Head}"
           Visibility="Visible">
           <effects:Zoombox
               x:Name="ImageBox"
               IsManipulationEnabled="True"
               MaxZoom="10"
               MinZoom="0.1"
               Visibility="{Binding Zoombox_Visibility}">
               <ContentControl Content="{Binding Viewing_Canvas}" />
           </effects:Zoombox>
       </Border>
           <StackPanel
               Grid.Column="1"
               Margin="10"
               HorizontalAlignment="Right"
               Orientation="Horizontal">
               <Button
                   Command="effects:ZoomCommands.Increase"
                   CommandParameter="2.0"
                   CommandTarget="{Binding ElementName=ImageBox}"
                   Content="Zoom In"
                   Style="{StaticResource StyleForResizeButtons}" />

               <Button
                   Command="effects:ZoomCommands.Decrease"
                   CommandParameter="2.0"
                   CommandTarget="{Binding ElementName=ImageBox}"
                   Content="Zoom Out"
                   Style="{StaticResource StyleForResizeButtons}" />

               <Button
                   Command="effects:ZoomCommands.Uniform"
                   CommandTarget="{Binding ElementName=ImageBox}"
                   Content="See Full Machine"
                   Style="{StaticResource StyleForResizeButtons}" />

               <Button
                   Command="effects:ZoomCommands.UniformToFill"
                   CommandTarget="{Binding ElementName=ImageBox}"
                   Content="Zoom To Machine Width"
                   Style="{StaticResource StyleForResizeButtons}" />
   
           </StackPanel>

</Grid>
</UserControl>

In the underlying Viewmodel, I had the following relevant code:

public Visibility Zoombox_Visibility { get => movZoombox_Visibility; set { movZoombox_Visibility = value; OnPropertyChanged(nameof(Zoombox_Visibility)); } }
public Canvas Viewing_Canvas { get => mdvViewing_Canvas; private set => mdvViewing_Canvas = value; }

Also, I wanted that immediately on loading, the Uniform to Fill Command was executed, this is something that I managed to do in the code-behind MachineLayoutControl.xaml.cs . You see that I only set the Zoombox to visible if the command is executed, to avoid "flickering" when the usercontrol is loading.

    public partial class MachineLayoutControl : UserControl
    {
        #region Constructors

        public MachineLayoutControl()
        {
            InitializeComponent();
            Loaded += MyWindow_Loaded;
        }

        #endregion Constructors

        #region EventHandlers

        private void MyWindow_Loaded(object sender, RoutedEventArgs e)
        {
            Application.Current.Dispatcher.BeginInvoke(
               DispatcherPriority.ApplicationIdle,
               new Action(() =>
               {
                   ZoomCommands.Uniform.Execute(null, ImageBox);
                   ((MachineLayoutControlViewModel)DataContext).Zoombox_Visibility = Visibility.Visible;
               }));
        }

        #endregion EventHandlers
    }

Result

JoachimAlly
  • 112
  • 7
2

Yet another version of the same kind of control. It has similar functionality as the others, but it adds:

  1. Touch support (drag/pinch)
  2. The image can be deleted (normally, the Image control locks the image on disk, so you cannot delete it).
  3. An inner border child, so the panned image doesn't overlap the border. In case of borders with rounded rectangles, look for ClippedBorder classes.

Usage is simple:

<Controls:ImageViewControl ImagePath="{Binding ...}" />

And the code:

public class ImageViewControl : Border
{
    private Point origin;
    private Point start;
    private Image image;

    public ImageViewControl()
    {
        ClipToBounds = true;
        Loaded += OnLoaded;
    }

    #region ImagePath

    /// <summary>
    ///     ImagePath Dependency Property
    /// </summary>
    public static readonly DependencyProperty ImagePathProperty = DependencyProperty.Register("ImagePath", typeof (string), typeof (ImageViewControl), new FrameworkPropertyMetadata(string.Empty, OnImagePathChanged));

    /// <summary>
    ///     Gets or sets the ImagePath property. This dependency property 
    ///     indicates the path to the image file.
    /// </summary>
    public string ImagePath
    {
        get { return (string) GetValue(ImagePathProperty); }
        set { SetValue(ImagePathProperty, value); }
    }

    /// <summary>
    ///     Handles changes to the ImagePath property.
    /// </summary>
    private static void OnImagePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var target = (ImageViewControl) d;
        var oldImagePath = (string) e.OldValue;
        var newImagePath = target.ImagePath;
        target.ReloadImage(newImagePath);
        target.OnImagePathChanged(oldImagePath, newImagePath);
    }

    /// <summary>
    ///     Provides derived classes an opportunity to handle changes to the ImagePath property.
    /// </summary>
    protected virtual void OnImagePathChanged(string oldImagePath, string newImagePath)
    {
    }

    #endregion

    private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
    {
        image = new Image {
                              //IsManipulationEnabled = true,
                              RenderTransformOrigin = new Point(0.5, 0.5),
                              RenderTransform = new TransformGroup {
                                                                       Children = new TransformCollection {
                                                                                                              new ScaleTransform(),
                                                                                                              new TranslateTransform()
                                                                                                          }
                                                                   }
                          };
        // NOTE I use a border as the first child, to which I add the image. I do this so the panned image doesn't partly obscure the control's border.
        // In case you are going to use rounder corner's on this control, you may to update your clipping, as in this example:
        // http://wpfspark.wordpress.com/2011/06/08/clipborder-a-wpf-border-that-clips/
        var border = new Border {
                                    IsManipulationEnabled = true,
                                    ClipToBounds = true,
                                    Child = image
                                };
        Child = border;

        image.MouseWheel += (s, e) =>
                                {
                                    var zoom = e.Delta > 0
                                                   ? .2
                                                   : -.2;
                                    var position = e.GetPosition(image);
                                    image.RenderTransformOrigin = new Point(position.X / image.ActualWidth, position.Y / image.ActualHeight);
                                    var st = (ScaleTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is ScaleTransform);
                                    st.ScaleX += zoom;
                                    st.ScaleY += zoom;
                                    e.Handled = true;
                                };

        image.MouseLeftButtonDown += (s, e) =>
                                         {
                                             if (e.ClickCount == 2)
                                                 ResetPanZoom();
                                             else
                                             {
                                                 image.CaptureMouse();
                                                 var tt = (TranslateTransform) ((TransformGroup) image.RenderTransform).Children.First(tr => tr is TranslateTransform);
                                                 start = e.GetPosition(this);
                                                 origin = new Point(tt.X, tt.Y);
                                             }
                                             e.Handled = true;
                                         };

        image.MouseMove += (s, e) =>
                               {
                                   if (!image.IsMouseCaptured) return;
                                   var tt = (TranslateTransform) ((TransformGroup) image.RenderTransform).Children.First(tr => tr is TranslateTransform);
                                   var v = start - e.GetPosition(this);
                                   tt.X = origin.X - v.X;
                                   tt.Y = origin.Y - v.Y;
                                   e.Handled = true;
                               };

        image.MouseLeftButtonUp += (s, e) => image.ReleaseMouseCapture();

        //NOTE I apply the manipulation to the border, and not to the image itself (which caused stability issues when translating)!
        border.ManipulationDelta += (o, e) =>
                                       {
                                           var st = (ScaleTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is ScaleTransform);
                                           var tt = (TranslateTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is TranslateTransform);

                                           st.ScaleX *= e.DeltaManipulation.Scale.X;
                                           st.ScaleY *= e.DeltaManipulation.Scale.X;
                                           tt.X += e.DeltaManipulation.Translation.X;
                                           tt.Y += e.DeltaManipulation.Translation.Y;

                                           e.Handled = true;
                                       };
    }

    private void ResetPanZoom()
    {
        var st = (ScaleTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is ScaleTransform);
        var tt = (TranslateTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is TranslateTransform);
        st.ScaleX = st.ScaleY = 1;
        tt.X = tt.Y = 0;
        image.RenderTransformOrigin = new Point(0.5, 0.5);
    }

    /// <summary>
    /// Load the image (and do not keep a hold on it, so we can delete the image without problems)
    /// </summary>
    /// <see cref="http://blogs.vertigo.com/personal/ralph/Blog/Lists/Posts/Post.aspx?ID=18"/>
    /// <param name="path"></param>
    private void ReloadImage(string path)
    {
        try
        {
            ResetPanZoom();
            // load the image, specify CacheOption so the file is not locked
            var bitmapImage = new BitmapImage();
            bitmapImage.BeginInit();
            bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
            bitmapImage.UriSource = new Uri(path, UriKind.RelativeOrAbsolute);
            bitmapImage.EndInit();
            image.Source = bitmapImage;
        }
        catch (SystemException e)
        {
            Console.WriteLine(e.Message);
        }
    }
}
Erik Vullings
  • 5,550
  • 2
  • 28
  • 23
  • 1
    Only problem I found was that if a path to an image is specified in the XAML, it tries to render it before the image object is constructed (i.e. before OnLoaded is called). To fix, I moved the "image = new Image ..." code , from the onLoaded method to the constructor. Thanks. – Mitch Dec 27 '12 at 20:39
  • Other problem is the image can be zoom out to small until we can do nothing and see nothing.I add a bit limitation: `if (image.ActualWidth*(st.ScaleX + zoom) < 200 || image.ActualHeight*(st.ScaleY + zoom) < 200) //don't zoom out too small. return;` in image.MouseWheel – huoxudong125 Jun 24 '15 at 07:48
2

@ Merk

For ur solution insted of lambda expression you can use following code:

//var tt = (TranslateTransform)((TransformGroup)image.RenderTransform).Children.First(tr => tr is TranslateTransform);
        TranslateTransform tt = null;
        TransformGroup transformGroup = (TransformGroup)grid.RenderTransform;
        for (int i = 0; i < transformGroup.Children.Count; i++)
        {
            if (transformGroup.Children[i] is TranslateTransform)
                tt = (TranslateTransform)transformGroup.Children[i];
        }

this code can be use as is for .Net Frame work 3.0 or 2.0

Hope It helps you :-)

nishantcop
  • 977
  • 1
  • 8
  • 24
2

This will zoom in and out as well as pan but keep the image within the bounds of the container. Written as a control so add the style to the App.xaml directly or through the Themes/Viewport.xaml.

For readability I've also uploaded this on gist and github

I've also packaged this up on nuget

PM > Install-Package Han.Wpf.ViewportControl

./Controls/Viewport.cs:

public class Viewport : ContentControl
{
    private bool _capture;
    private FrameworkElement _content;
    private Matrix _matrix;
    private Point _origin;

    public static readonly DependencyProperty MaxZoomProperty =
        DependencyProperty.Register(
            nameof(MaxZoom),
            typeof(double),
            typeof(Viewport),
            new PropertyMetadata(0d));

    public static readonly DependencyProperty MinZoomProperty =
        DependencyProperty.Register(
            nameof(MinZoom),
            typeof(double),
            typeof(Viewport),
            new PropertyMetadata(0d));

    public static readonly DependencyProperty ZoomSpeedProperty =
        DependencyProperty.Register(
            nameof(ZoomSpeed),
            typeof(float),
            typeof(Viewport),
            new PropertyMetadata(0f));

    public static readonly DependencyProperty ZoomXProperty =
        DependencyProperty.Register(
            nameof(ZoomX),
            typeof(double),
            typeof(Viewport),
            new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static readonly DependencyProperty ZoomYProperty =
        DependencyProperty.Register(
            nameof(ZoomY),
            typeof(double),
            typeof(Viewport),
            new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static readonly DependencyProperty OffsetXProperty =
        DependencyProperty.Register(
            nameof(OffsetX),
            typeof(double),
            typeof(Viewport),
            new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static readonly DependencyProperty OffsetYProperty =
        DependencyProperty.Register(
            nameof(OffsetY),
            typeof(double),
            typeof(Viewport),
            new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static readonly DependencyProperty BoundsProperty =
        DependencyProperty.Register(
            nameof(Bounds),
            typeof(Rect),
            typeof(Viewport),
            new FrameworkPropertyMetadata(default(Rect), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public Rect Bounds
    {
        get => (Rect) GetValue(BoundsProperty);
        set => SetValue(BoundsProperty, value);
    }

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

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

    public double OffsetX
    {
        get => (double) GetValue(OffsetXProperty);
        set => SetValue(OffsetXProperty, value);
    }

    public double OffsetY
    {
        get => (double) GetValue(OffsetYProperty);
        set => SetValue(OffsetYProperty, value);
    }

    public float ZoomSpeed
    {
        get => (float) GetValue(ZoomSpeedProperty);
        set => SetValue(ZoomSpeedProperty, value);
    }

    public double ZoomX
    {
        get => (double) GetValue(ZoomXProperty);
        set => SetValue(ZoomXProperty, value);
    }

    public double ZoomY
    {
        get => (double) GetValue(ZoomYProperty);
        set => SetValue(ZoomYProperty, value);
    }

    public Viewport()
    {
        DefaultStyleKey = typeof(Viewport);

        Loaded += OnLoaded;
        Unloaded += OnUnloaded;
    }

    private void Arrange(Size desired, Size render)
    {
        _matrix = Matrix.Identity;

        var zx = desired.Width / render.Width;
        var zy = desired.Height / render.Height;
        var cx = render.Width < desired.Width ? render.Width / 2.0 : 0.0;
        var cy = render.Height < desired.Height ? render.Height / 2.0 : 0.0;

        var zoom = Math.Min(zx, zy);

        if (render.Width > desired.Width &&
            render.Height > desired.Height)
        {
            cx = (desired.Width - (render.Width * zoom)) / 2.0;
            cy = (desired.Height - (render.Height * zoom)) / 2.0;

            _matrix = new Matrix(zoom, 0d, 0d, zoom, cx, cy);
        }
        else
        {
            _matrix.ScaleAt(zoom, zoom, cx, cy);
        }
    }

    private void Attach(FrameworkElement content)
    {
        content.MouseMove += OnMouseMove;
        content.MouseLeave += OnMouseLeave;
        content.MouseWheel += OnMouseWheel;
        content.MouseLeftButtonDown += OnMouseLeftButtonDown;
        content.MouseLeftButtonUp += OnMouseLeftButtonUp;
        content.SizeChanged += OnSizeChanged;
        content.MouseRightButtonDown += OnMouseRightButtonDown;
    }

    private void ChangeContent(FrameworkElement content)
    {
        if (content != null && !Equals(content, _content))
        {
            if (_content != null)
            {
                Detatch();
            }

            Attach(content);
            _content = content;
        }
    }

    private double Constrain(double value, double min, double max)
    {
        if (min > max)
        {
            min = max;
        }

        if (value <= min)
        {
            return min;
        }

        if (value >= max)
        {
            return max;
        }

        return value;
    }

    private void Constrain()
    {
        var x = Constrain(_matrix.OffsetX, _content.ActualWidth - _content.ActualWidth * _matrix.M11, 0);
        var y = Constrain(_matrix.OffsetY, _content.ActualHeight - _content.ActualHeight * _matrix.M22, 0);

        _matrix = new Matrix(_matrix.M11, 0d, 0d, _matrix.M22, x, y);
    }

    private void Detatch()
    {
        _content.MouseMove -= OnMouseMove;
        _content.MouseLeave -= OnMouseLeave;
        _content.MouseWheel -= OnMouseWheel;
        _content.MouseLeftButtonDown -= OnMouseLeftButtonDown;
        _content.MouseLeftButtonUp -= OnMouseLeftButtonUp;
        _content.SizeChanged -= OnSizeChanged;
        _content.MouseRightButtonDown -= OnMouseRightButtonDown;
    }

    private void Invalidate()
    {
        if (_content != null)
        {
            Constrain();

            _content.RenderTransformOrigin = new Point(0, 0);
            _content.RenderTransform = new MatrixTransform(_matrix);
            _content.InvalidateVisual();

            ZoomX = _matrix.M11;
            ZoomY = _matrix.M22;

            OffsetX = _matrix.OffsetX;
            OffsetY = _matrix.OffsetY;

            var rect = new Rect
            {
                X = OffsetX * -1,
                Y = OffsetY * -1,
                Width = ActualWidth,
                Height = ActualHeight
            };

            Bounds = rect;
        }
    }

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        _matrix = Matrix.Identity;
    }

    protected override void OnContentChanged(object oldContent, object newContent)
    {
        base.OnContentChanged(oldContent, newContent);

        if (Content is FrameworkElement element)
        {
            ChangeContent(element);
        }
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        if (Content is FrameworkElement element)
        {
            ChangeContent(element);
        }

        SizeChanged += OnSizeChanged;
        Loaded -= OnLoaded;
    }

    private void OnMouseLeave(object sender, MouseEventArgs e)
    {
        if (_capture)
        {
            Released();
        }
    }

    private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (IsEnabled && !_capture)
        {
            Pressed(e.GetPosition(this));
        }
    }

    private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        if (IsEnabled && _capture)
        {
            Released();
        }
    }

    private void OnMouseMove(object sender, MouseEventArgs e)
    {
        if (IsEnabled && _capture)
        {
            var position = e.GetPosition(this);

            var point = new Point
            {
                X = position.X - _origin.X,
                Y = position.Y - _origin.Y
            };

            var delta = point;
            _origin = position;

            _matrix.Translate(delta.X, delta.Y);

            Invalidate();
        }
    }

    private void OnMouseRightButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (IsEnabled)
        {
            Reset();
        }
    }

    private void OnMouseWheel(object sender, MouseWheelEventArgs e)
    {
        if (IsEnabled)
        {
            var scale = e.Delta > 0 ? ZoomSpeed : 1 / ZoomSpeed;
            var position = e.GetPosition(_content);

            var x = Constrain(scale, MinZoom / _matrix.M11, MaxZoom / _matrix.M11);
            var y = Constrain(scale, MinZoom / _matrix.M22, MaxZoom / _matrix.M22);

            _matrix.ScaleAtPrepend(x, y, position.X, position.Y);

            ZoomX = _matrix.M11;
            ZoomY = _matrix.M22;

            Invalidate();
        }
    }

    private void OnSizeChanged(object sender, SizeChangedEventArgs e)
    {
        if (_content?.IsMeasureValid ?? false)
        {
            Arrange(_content.DesiredSize, _content.RenderSize);

            Invalidate();
        }
    }

    private void OnUnloaded(object sender, RoutedEventArgs e)
    {
        Detatch();

        SizeChanged -= OnSizeChanged;
        Unloaded -= OnUnloaded;
    }

    private void Pressed(Point position)
    {
        if (IsEnabled)
        {
            _content.Cursor = Cursors.Hand;
            _origin = position;
            _capture = true;
        }
    }

    private void Released()
    {
        if (IsEnabled)
        {
            _content.Cursor = null;
            _capture = false;
        }
    }

    private void Reset()
    {
        _matrix = Matrix.Identity;

        if (_content != null)
        {
            Arrange(_content.DesiredSize, _content.RenderSize);
        }

        Invalidate();
    }
}

./Themes/Viewport.xaml:

<ResourceDictionary ... >

    <Style TargetType="{x:Type controls:Viewport}"
           BasedOn="{StaticResource {x:Type ContentControl}}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type controls:Viewport}">
                    <Border BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            Background="{TemplateBinding Background}">
                        <Grid ClipToBounds="True"
                              Width="{TemplateBinding Width}"
                              Height="{TemplateBinding Height}">
                            <Grid x:Name="PART_Container">
                                <ContentPresenter x:Name="PART_Presenter" />
                            </Grid>
                        </Grid>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

</ResourceDictionary>

./App.xaml

<Application ... >
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>

                <ResourceDictionary Source="./Themes/Viewport.xaml"/>

            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Usage:

<viewers:Viewport>
    <Image Source="{Binding}"/>
</viewers:Viewport>

Any issues, give me a shout.

Happy coding :)

Adam H
  • 561
  • 1
  • 9
  • 20
  • Great, I love this version. Any way to add scrollbars to it? – Etienne Charland Jun 30 '18 at 05:59
  • By the way you're using dependency properties wrong. For Zoom and Translate, you can't put the code in the property setter as it doesn't get called at all when binding. You need to register Change and Coerce handlers on the dependency property itself and do the work in there. – Etienne Charland Jun 30 '18 at 06:04
  • I've massively changed this answer since writing it, ill update it with fixes for some of the issues i've had using it in production later – Adam H Jul 04 '18 at 23:01
  • This solution is great, but I can't quite figure out why the mouse wheel scroll function seems to have a strange pull to one direction when zooming in and out of an image, instead of using the mouse pointer position as the zoom origin. Am I crazy or is there some logical explanation for this? – Paul Karkoska Aug 09 '18 at 21:22
  • I'm struggling trying to get this to work consistently within a ScrollViewer control. I modified it a bit to use the cusor position as the scale origin (to zoom in and out using the mouse position), but could really use some input on how to get it to work inside of a ScrollViewer. Thanks! – Paul Karkoska Aug 10 '18 at 15:32
  • I've finally managed to finish this. I've uploaded it to my gist page here https://gist.github.com/ahancock1/a2958f7eedd3e8d13e6ddda8feef0a65 - I may even throw it in a nuget package as well. – Adam H Aug 14 '18 at 18:15
  • @AdamH can you also add a minimap to see where eon the image the user is? – Karina Kozarova Apr 13 '22 at 08:10
0

One addition to the superb solution provided by @Wiesław Šoltés answer above

The existing code resets the image position using right click, but I am more accustomed to doing that with a double click. Just replace the existing child_MouseLeftButtonDown handler:

private void child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (child != null)
        {
            var tt = GetTranslateTransform(child);
            start = e.GetPosition(this);
            origin = new Point(tt.X, tt.Y);
            this.Cursor = Cursors.Hand;
            child.CaptureMouse();
        }
    }

With this:

private void child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if ((e.ChangedButton == MouseButton.Left && e.ClickCount == 1))
        {
            if (child != null)
            {
                var tt = GetTranslateTransform(child);
                start = e.GetPosition(this);
                origin = new Point(tt.X, tt.Y);
                this.Cursor = Cursors.Hand;
                child.CaptureMouse();
            }
        }

        if ((e.ChangedButton == MouseButton.Left && e.ClickCount == 2))
        {
            this.Reset();
        }
    }
TripleAntigen
  • 2,221
  • 1
  • 31
  • 44