2

I'm implementing a zoom behavior on my canvas. The canvas and the ScrollViewer react correctly but the children inside the canvas move weirdly, drifting from their original position. As a result, after a few of zoom in/out operation the children are in completely different position or in some cases even outside the canvas!

https://ibb.co/TwRW40Z https://ibb.co/16JKR5C

<ScrollViewer x:Name="ScrollViewerCanvas" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" CanContentScroll="True" >
    <MyCanvas Panel.ZIndex="0" x:Name="nodeGraph" Width="1200" Height="790" HorizontalAlignment="Center" />
</ScrollViewer>

protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)
{
    if (Keyboard.Modifiers != ModifierKeys.Control)
        return;

    float scaleFactor = Zoomfactor;
    if (e.Delta < 0)
    {
        scaleFactor = 1f / scaleFactor;
    }

    Point mousePostion = e.GetPosition(this);

    Matrix scaleMatrix = _transform.Matrix;
    scaleMatrix.ScaleAt(scaleFactor, scaleFactor, mousePostion.X, mousePostion.Y);
    _transform.Matrix = scaleMatrix;

    foreach (UIElement child in Children)
    {
        Canvas.SetLeft(child, Canvas.GetLeft(child) * scaleFactor);
        Canvas.SetTop(child, Canvas.GetTop(child) * scaleFactor);

        child.RenderTransform = _transform;
    }

    this.LayoutTransform = _transform;
}
Dizzy
  • 23
  • 4

2 Answers2

2

I think you are getting into trouble because you are moving the objects across the canvas. I doubt if this is desirable. You rather should adjust the ScrollViewer position to keep the objects in position.

The following code encapsulates the zooming into an attached behavior.

To make it work properly you should always set ZoomBehavior.IsEnabled="True" and also bind the parent ScrollViewer to the ZoomBehavior.ScrollViewer attached property. The ZoomBehavior.ZoomFactor is optional and defaults to 0.1:

Usage

<ScrollViewer x:Name="MainScrollViewer"
              CanContentScroll="False"
              Width="500" Height="500"
              VerticalScrollBarVisibility="Auto"
              HorizontalScrollBarVisibility="Auto">
  <Canvas Width="300" Height="300"
          local:ZoomBehavior.IsEnabled="True"
          local:ZoomBehavior.ZoomFactor="0.1"
          local:ZoomBehavior.ScrollViewer="{Binding ElementName=MainScrollViewer}"
          Background="DarkGray">
    <Ellipse Fill="DarkOrange"
             Height="100" Width="100"
             Canvas.Top="100" Canvas.Left="100" />
  </Canvas>
</ScrollViewer>

ZoomBehavior.cs

public class ZoomBehavior : DependencyObject
{
  #region IsEnabled attached property

  // Required
  public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
    "IsEnabled", typeof(bool), typeof(ZoomBehavior), new PropertyMetadata(default(bool), ZoomBehavior.OnIsEnabledChanged));
    
  public static void SetIsEnabled(DependencyObject attachingElement, bool value) => attachingElement.SetValue(ZoomBehavior.IsEnabledProperty, value);

  public static bool GetIsEnabled(DependencyObject attachingElement) => (bool) attachingElement.GetValue(ZoomBehavior.IsEnabledProperty);

  #endregion

  #region ZoomFactor attached property

  // Optional
  public static readonly DependencyProperty ZoomFactorProperty = DependencyProperty.RegisterAttached(
    "ZoomFactor", typeof(double), typeof(ZoomBehavior), new PropertyMetadata(0.1));

  public static void SetZoomFactor(DependencyObject attachingElement, double value) => attachingElement.SetValue(ZoomBehavior.ZoomFactorProperty, value);

  public static double GetZoomFactor(DependencyObject attachingElement) => (double) attachingElement.GetValue(ZoomBehavior.ZoomFactorProperty);

  #endregion

  #region ScrollViewer attached property

  // Optional
  public static readonly DependencyProperty ScrollViewerProperty = DependencyProperty.RegisterAttached(
    "ScrollViewer", typeof(ScrollViewer), typeof(ZoomBehavior), new PropertyMetadata(default(ScrollViewer)));

  public static void SetScrollViewer(DependencyObject attachingElement, ScrollViewer value) => attachingElement.SetValue(ZoomBehavior.ScrollViewerProperty, value);

  public static ScrollViewer GetScrollViewer(DependencyObject attachingElement) => (ScrollViewer) attachingElement.GetValue(ZoomBehavior.ScrollViewerProperty);

  #endregion
  private static void OnIsEnabledChanged(DependencyObject attachingElement, DependencyPropertyChangedEventArgs e)
  {
    if (!(attachingElement is FrameworkElement frameworkElement))
    {
      throw new ArgumentException("Attaching element must be of type FrameworkElement");
    }

    bool isEnabled = (bool) e.NewValue;
    if (isEnabled)
    {
      frameworkElement.PreviewMouseWheel += ZoomBehavior.Zoom_OnMouseWheel;
      if (ZoomBehavior.TryGetScaleTransform(frameworkElement, out _))
      {
        return;
      }

      if (frameworkElement.LayoutTransform is TransformGroup transformGroup)
      {
        transformGroup.Children.Add(new ScaleTransform());
      }
      else
      {
        frameworkElement.LayoutTransform = new ScaleTransform();
      }
    }
    else
    {
      frameworkElement.PreviewMouseWheel -= ZoomBehavior.Zoom_OnMouseWheel;
    }
  }

  private static void Zoom_OnMouseWheel(object sender, MouseWheelEventArgs e)
  {
    var zoomTargetElement = sender as FrameworkElement;

    Point mouseCanvasPosition = e.GetPosition(zoomTargetElement);
    double scaleFactor = e.Delta > 0
      ? ZoomBehavior.GetZoomFactor(zoomTargetElement)
      : -1 * ZoomBehavior.GetZoomFactor(zoomTargetElement);
      
    ZoomBehavior.ApplyZoomToAttachedElement(mouseCanvasPosition, scaleFactor, zoomTargetElement);

    ZoomBehavior.AdjustScrollViewer(mouseCanvasPosition, scaleFactor, zoomTargetElement);
  }

  private static void ApplyZoomToAttachedElement(Point mouseCanvasPosition, double scaleFactor, FrameworkElement zoomTargetElement)
  {
    if (!ZoomBehavior.TryGetScaleTransform(zoomTargetElement, out ScaleTransform scaleTransform))
    {
      throw new InvalidOperationException("No ScaleTransform found");
    }

    scaleTransform.CenterX = mouseCanvasPosition.X;
    scaleTransform.CenterY = mouseCanvasPosition.Y;

    scaleTransform.ScaleX = Math.Max(0.1, scaleTransform.ScaleX + scaleFactor);
    scaleTransform.ScaleY = Math.Max(0.1, scaleTransform.ScaleY + scaleFactor);
  }

  private static void AdjustScrollViewer(Point mouseCanvasPosition, double scaleFactor, FrameworkElement zoomTargetElement)
  {
    ScrollViewer scrollViewer = ZoomBehavior.GetScrollViewer(zoomTargetElement);
    if (scrollViewer == null)
    {
      return;
    }

    scrollViewer.ScrollToHorizontalOffset(scrollViewer.HorizontalOffset + mouseCanvasPosition.X * scaleFactor);
    scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + mouseCanvasPosition.Y * scaleFactor);
  }

  private static bool TryGetScaleTransform(FrameworkElement frameworkElement, out ScaleTransform scaleTransform)
  {
    // C# 8.0 Switch Expression
    scaleTransform = frameworkElement.LayoutTransform switch
    {
      TransformGroup transformGroup => transformGroup.Children.OfType<ScaleTransform>().FirstOrDefault(),
      ScaleTransform transform => transform,
      _ => null
    };

    return scaleTransform != null;
  }
}
BionicCode
  • 1
  • 4
  • 28
  • 44
  • Can you explain how both scrollviewer and canvas to be reset when image source is changed? – H K Jun 07 '22 at 18:10
  • @HK It really depends on your context. Generally, remove the old element and add the new element. In your case you probably only want to change the source URI. Assuming that you bind an Image element to a source, you then add a e.g. `IsZoomInvalid` attached property to the behavior. You can bind it to the source you bind your image to. Set it to `true` to trigger the behavior to reset the scroll viewer position e.g. to top-left. Then after the reset is completed, you set the `IsZoomInvalid` property back to false. – BionicCode Jun 07 '22 at 22:28
  • @HK But as I said before, I don't know your context... Maybe a routed command bound to a button makes more sense (instead of the property). – BionicCode Jun 07 '22 at 22:29
0

I'm not clear what you're trying to do but the following zooms around where the mouse position is:

         PreviewMouseWheel="nodeGraph_PreviewMouseWheel"
        >
    <Grid>
        <ScrollViewer x:Name="ScrollViewerCanvas" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" CanContentScroll="True" >
            <Canvas  x:Name="nodeGraph" Width="1200" Height="790" HorizontalAlignment="Center" 
                
                     >
                <Canvas.RenderTransform>
                    <ScaleTransform CenterX="0" CenterY="0" ScaleX="1" ScaleY="1"  x:Name="st"/>
                </Canvas.RenderTransform>

                <Rectangle Fill="Red" Width="100" Height="50"
                           Canvas.Left="200"
                           Canvas.Top="400"/>
                <Rectangle Fill="Blue" Width="100" Height="50"
                           Canvas.Left="500"
                           Canvas.Top="100"/>
            </Canvas>
        </ScrollViewer>
    </Grid>
    </Window>

and

    public MainWindow()
    {
        InitializeComponent();
    }

    float factor = 1;
    private void nodeGraph_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        if (e.Delta < 0)
        {
            factor -=.1f;
        }
        else
        {
            factor += .1f;
        }
        Point pt= Mouse.GetPosition(nodeGraph);
        st.CenterX = pt.X;
        st.CenterY = pt.Y;
        st.ScaleY = st.ScaleX = factor;
    }
Andy
  • 11,864
  • 2
  • 17
  • 20