Search code examples
wpfzoomingscrollviewerreactiveui

Zoom to mouse position inside a scrollview


So I've done a ZoomControl that uses a border and an image

<UserControl x:Class="ImageViewer.Controls.ZoomControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ImageViewer.Controls"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Border x:Name="BorderImage">
        <Image HorizontalAlignment="Left" VerticalAlignment="Top" x:Name="RenderingImage" RenderTransformOrigin="0,0"  Stretch="None" Source="{Binding}" RenderTransform="{Binding}"/>
    </Border>

This control is nested inside a ScrollViewer

   <ScrollViewer x:Name="ScollViewerImage"  Grid.Column="2" Grid.Row="0" Grid.RowSpan="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">            
            <Controls:ZoomControl x:Name="RenderingImage" ViewModel="{Binding}" ClipToBounds="True"></Controls:ZoomControl>
        </ScrollViewer>

So I've built a zoom to mouse position function

private void DoZoom(double deltaZoom, Point mousePosition)
        {
            var scaleTransform = GetScaleTransform();
            var translateTransform = GetTranslateTransform();

            if (!(deltaZoom > 0) && (scaleTransform.ScaleX < .4 || scaleTransform.ScaleY < .4))
                return;

            var mousePositionAfterScaleX = mousePosition.X * deltaZoom;
            var mousePositionAfterScaleY = mousePosition.Y * deltaZoom;

            var newMousePositionX = mousePosition.X - mousePositionAfterScaleX;
            var newMousePositionY = mousePosition.Y - mousePositionAfterScaleY;

            var newTranslateX = newMousePositionX - mousePosition.X;
            var newTranslateY = newMousePositionY - mousePosition.Y;

            var translateX = newTranslateX + translateTransform.X;
            var translateY = newTranslateY + translateTransform.Y;

            scaleTransform.ScaleX += deltaZoom;
            scaleTransform.ScaleY += deltaZoom;

            _currentZoom = scaleTransform.ScaleX;   

            ChangeTranslateTransofrm(translateX - overflowWidth, translateY - overflowHeight);
            UpdateScaleTransfromValue();
        }

This works fine as long as the image fits inside the ScrollViewer size. But after the image is bigger than the ScrollVIewer control ( it shows up the scrollbars) and whenever I zoom in my mouse position is no more on the same point. I am sure it has something to do with the fact that the scrollbar are visible, but I can't figure out the math to make the mouse stick to the same position after scrollbar are visible.


Solution

  • So after 2 days of try and error I've found the solution for my specific problem. First of all there are 2 main problems.

    1. Scrollbars are on auto ( they hide/show at run time based on the width.height of the child inside the ScrollViewer ). Because of that the image inside changes it's translate transform.
    2. The scrollbar position (offset) does not persist on the same position after a zoom/in out action(child get bigger or smaller).

    First problem was solved by adding 2 borders to the ScrollViewer that will keep the space needed for scrollbar occupied as long as the scrollbars are not visibile.

    <ScrollViewer x:Name="ScollViewerImage" Grid.Column="2" Grid.Row="0" Grid.RowSpan="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" >
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition/>
                </Grid.RowDefinitions>
                <Border Grid.Column="1" Width="{x:Static SystemParameters.VerticalScrollBarWidth}">
                    <Border.Style>
                        <Style TargetType="Border">
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding ComputedVerticalScrollBarVisibility, ElementName=ScollViewerImage}"
                                             Value="Visible">
                                    <Setter Property="Visibility" Value="Collapsed"/>
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </Border.Style>
                </Border>
                <Border Grid.Row="1" Width="{x:Static SystemParameters.HorizontalScrollBarHeight}">
                    <Border.Style>
                        <Style TargetType="Border">
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding ComputedHorizontalScrollBarVisibility, ElementName=ScollViewerImage}"
                                             Value="Visible">
                                    <Setter Property="Visibility" Value="Collapsed"/>
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </Border.Style>
                </Border>
                <Controls:ZoomControl Grid.Column="0" Grid.Row="0" x:Name="RenderingImage" ViewModel="{Binding}" ClipToBounds="True"></Controls:ZoomControl>
            </Grid>
    
        </ScrollViewer>
    

    For the second problem whenever a scroll is done I save the offset. I also sahve the zoom size (ScrollableHeight and Scorllable Width) and when they change (Zoom in/zoom out has been done). I reposition the scrollbar at is desired position.

            private void SetScrollbarOffset(ScrollViewer scrollViewer, double verticalChange, double horizontalChange)
            {
                // after each move of the scrollbar we save the current offsets
                _currentVerticalOffset = scrollViewer.VerticalOffset;
                _currentHorizonalOffset = scrollViewer.HorizontalOffset;
    
                // we check if there was a zoom in/out perfomed
                if (_scrollableHeight != scrollViewer.ScrollableHeight)
                {
                    // we save the current zoom in/out scrollable height
                    _scrollableHeight = scrollViewer.ScrollableHeight;
                    // we move the scrollbar to the position needed to persist the mouse under the same point in the image
                    scrollViewer.ScrollToVerticalOffset(_currentVerticalOffset - verticalChange);
                }
    
                if (_scrollableWidth != scrollViewer.ScrollableWidth)
                {
                    _scrollableWidth = scrollViewer.ScrollableWidth;
                    scrollViewer.ScrollToHorizontalOffset(_currentHorizonalOffset - horizontalChange);
                }
            }
    

    This last method is called from SizeChangedEvent

            public void SizeChange(ScrollChangedEventArgs e)
            {
                var scrollViewer = (e.OriginalSource as ScrollViewer);
    
                SetScrollbarOffset(scrollViewer, e.VerticalChange, e.HorizontalOffset);
            }