ygoe
ygoe

Reputation: 20384

Determine the inner width of a ScrollViewer

I have a <ScrollViewer> in a WPF application on .NET Framework 4.7. Inside that ScrollViewer is a <Border>. That border doesn't necessarily take up all the space it gets. A ScaleTransform is applied to it to allow zooming in and out.

When zooming in and out with the mouse wheel, the content position under the mouse cursor should remain the same, for the old and new scaling. For this, I need to do some maths to determine where the mouse cursor is in the old and new scaling.

I already have the scroll position of the ScrollViewer, but I can't find the virtual or scrollable or content width/height of the ScrollViewer. How can I determine the total width or height of the content area of the ScrollViewer? That is the minimum size when no scrollbars would be displayed. I can't just use the actual size of the inner Border because that is constrained and may not fill the scrollable area in both dimensions. I need to get that information directly from the ScrollViewer or its scrollbars (which I have no access to).

Here's a part of the XAML:

<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
              PreviewMouseWheel="ScrollViewer_PreviewMouseWheel">
    <Border HorizontalAlignment="Center" VerticalAlignment="Center" Margin="100">
        <Grid>
            <Grid.LayoutTransform>
                <ScaleTransform .../>
            </Grid.LayoutTransform>
            <dt:DrawingCanvas .../>
        </Grid>
    </Border>
</scrollViewer>

The DrawingCanvas is the object that determines the size. It's horizontally and vertically centered in the scroll viewer. If it exceeds the available screen space in one or both dimensions, a scrollbar appears.

I'm using this code to get the current position in the ScrollViewer_PreviewMouseWheel event handler:

private void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs args)
{
    // Determine the mouse cursor position relative to the content
    var sv = (ScrollViewer)sender;
    Point mousePoint = args.GetPosition(sv);
    double x = mousePoint.X + sv.HorizontalOffset;
    double y = mousePoint.Y + sv.VerticalOffset;
    double xFrac = x / sv.ContentWidth;
    double yFrac = y / sv.ContentHeight;

    // Updating scale factor and layout...

    // Move scrollbars to keep the same content under the mouse cursor
    sv.ScrollToHorizontalOffset(...);
    sv.ScrollToVerticalOffset(...);
}

Specifically I'm looking for the non-existent properties of ContentWidth and ContentHeight from the scroll viewer. How can I get those values?

Upvotes: 0

Views: 1179

Answers (1)

BionicCode
BionicCode

Reputation: 28988

The ScrollViewer is described as follows:

ScrollViewer.ViewportWidth returns the pure visible content area.
Content elements that doesn't fit into the ScrollViewer.ViewportWidth are clipped and the horizontal scroll bar may show (if not disabled). In this case the ScrollViewer.ScrollableWidth will return the hidden/clipped part of the content width, the part that can be scrolled into the viewport.
For example, if the ScrollViewer.ViewportWidth returns 50 and the content has a width of 60, then the content will be clipped at 50 and ScrollViewer.ScrollableWidth will return 10:

scrollable_width = content_width - viewport_width = 60 - 50 = 10

ScrollViewer.ExtentWidth will show you the total width, which is equivalent to the content's width. In case the content exceeds the width of the viewport, ScrollViewer.ExtentWidth can be also described by the sum of ScrollViewer.ScrollableWidth and ScrollViewer.ViewportWidth:

ScrollViewer.ExtentWidth = scroll_viewer_content_width

Or

ScrollViewer.ExtentWidth = ScrollViewer.ViewportWidth + ScrollViewer.ScrollableWidth. 

ScrollViewer.ActualWidth will return the current width of the ScrollViewer control itself (without the hidden/clipped content part but including the scroll bars).

Everything discussed so far applies to the ScrolViewer related height values too.

Regarding your desired zoom feature:

The scaling should target the Canvas. You can scale the Canvas at mouse cursor position by setting the ScaleTransform.CenterX and ScaleTransform.CenterY to the current mouse position. Scaling will trigger a layout pass, which will trigger the ScrollViewer to adjust to the changed content. This requires to reposition the content by scrolling to the new position in order to avoid the content to be scrolled away from the scaling origin.

The scaling factor applies to the complete coordinate system relative to the scaled element e.g., Canvas.
This means when the mouse position is at P1(5;5) and a scaling factor of 2 is applied, the mouse position will move to P2(10;10). The new mouse position P2 describes the offset that must be added to the current ScrollViewer offsets in order to keep the zoomed object in place:

horizontal_scroll_offset = Pmouse_X * scaleFactor + current_horizontal_scroll_offset

and

vertical_scroll_offset = Pmouse_Y * scaleFactor + current_vertical_scroll_offset

In order to scroll the the ScrollViewer by pixels (mouse position is returned in pixels), the ScrollViewer must be configured to use physical scrolling units instead of logical units. ScrollViewer.CanContentScroll must be set to false (which is the default).
Also the PreviewMouseWheel event handler needs to have access to the ScaleTransform of the Canvas (or FrameworkElement in general) in order to know the scale factor and to the ScrollViewer in order to adjust the scroll offset after applying the scaling. The access could be either directly or via data binding.

For convenience, I have created an attached behavior ZoomBehavior.
This behavior zooms in and out every element that is a FrameworElement on mouse wheel input.

To enable zoom, set the ZoomBehavior.IsEnabled to true.
Optionally, set the zoom factor ZoomBehavior.ZoomFactor (default is 0.1). Optionally, bind or set a ScrollViewer to ZoomBehavior.ScrollViewer, if you wish to adjust scroll position. Note that ScrollViewer.CanContentScroll should be set to false for proper behavior.

The following example enables zooming on a Canvas element:

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

ZoomBehavior.cs
The code uses a switch expression, which is a feature of C# 8.0.
If your environment doesn't support this language version, you need to convert the expression to a classic switch statement (with two labels).

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), 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;
  }
}

Upvotes: 4

Related Questions