Reputation: 20384
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
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