Search code examples
wpfxamlzooming

WPF Zoom + Scrollbar?


I'm trying to zoom some contents within scrollviewer.

The zoom behavior I'm looking for is that of a RenderTransform+ScaleTransform. But this does not work with the ScrollViewer.

Using LayoutTransform+ScaleTransform, the scrollviewer does get affected (ContentTemplate1 only), but does not behave like a zoom.

Assuming ContentTemplate1/ContentTemplate2 cannot be changed (ie, 3rd party controls), how can I get zoom to work with a scrollviewer?

<Grid>
    <Grid.Resources>
        <!-- Content type 1 -->
        <DataTemplate x:Key="ContentTemplate1">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="150"/>
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>
                <TextBlock Background="DodgerBlue" Text="Left"/>
                <TextBlock Grid.Column="1" Background="DarkGray" Text="Right"/>
            </Grid>
        </DataTemplate>

        <!-- Content type 2 -->
        <DataTemplate x:Key="ContentTemplate2">
            <Viewbox>
                <TextBlock Background="DodgerBlue" Text="Scale to fit" Width="100" Height="70" Foreground="White" TextAlignment="Center"/>
            </Viewbox>
        </DataTemplate>
    </Grid.Resources>
    <Grid.RowDefinitions>
        <RowDefinition />
        <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>

    <TabControl>
        <!-- Content 1 -->
        <TabControl.Resources>
            <ScaleTransform x:Key="ScaleTransform"
                            ScaleX="{Binding ElementName=ZoomSlider,Path=Value}"
                            ScaleY="{Binding ElementName=ZoomSlider,Path=Value}" />
        </TabControl.Resources>
        <TabItem Header="Content 1">
            <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
                <ContentControl ContentTemplate="{StaticResource ContentTemplate1}" Margin="10" RenderTransformOrigin=".5,.5">
                    <!-- Affects scrollviewer, but does not behave like a zoom -->
                    <!--<FrameworkElement.LayoutTransform>
                        <StaticResource ResourceKey="ScaleTransform" />
                    </FrameworkElement.LayoutTransform>-->

                    <!-- Expected zoom behavior, but doesn't affect scrollviewer -->
                    <FrameworkElement.RenderTransform>
                        <StaticResource ResourceKey="ScaleTransform" />
                    </FrameworkElement.RenderTransform>
                </ContentControl>
            </ScrollViewer>
        </TabItem>
        <!-- Content 2 -->
        <TabItem Header="Content 2">
            <ContentControl ContentTemplate="{StaticResource ContentTemplate2}" Margin="10" RenderTransformOrigin=".5,.5">
                <!-- Affects scrollviewer, but does not behave like a zoom -->
                <!--<FrameworkElement.LayoutTransform>
                        <StaticResource ResourceKey="ScaleTransform" />
                    </FrameworkElement.LayoutTransform>-->

                <!-- Expected zoom behavior, but doesn't affect scrollviewer -->
                <FrameworkElement.RenderTransform>
                    <StaticResource ResourceKey="ScaleTransform" />
                </FrameworkElement.RenderTransform>
            </ContentControl>

        </TabItem>
    </TabControl>

    <StackPanel Grid.Row="1" Orientation="Horizontal">
        <!-- Zoom -->
        <Slider x:Name="ZoomSlider"
                Width="100"
                Maximum="5"
                Minimum="0.1"
                Orientation="Horizontal"
                Value="1" />

        <!-- Autofit -->
        <CheckBox Content="Autofit?" x:Name="AutoFitCheckBox" />
    </StackPanel>
</Grid>

Solution

  • To make the zoomed elements get the exact RenderTransform look, we may as well stick with RenderTransform, and instead tell the ScrollViewer how to behave by implementing our own scrolling logic. This approach is based on this excellent tutorial:

    https://web.archive.org/web/20140809230047/http://tech.pro/tutorial/907/wpf-tutorial-implementing-iscrollinfo

    We create our own custom "ZoomableContentControl" which implements IScrollInfo and tell the ScrollViewer to get its scrolling logic from there (ScrollViewer.CanContentScroll = True). The magic happens in ArrangeOverride() where we play with ExtentWidth/ExtentHeight and RenderTransformOrigin.

    public class ZoomableContentControl : ContentControl, IScrollInfo
    {
        public ZoomableContentControl()
        {
            this.RenderTransformOrigin = new Point(0.5, 0.5);
        }
    
        private ScaleTransform _scale = null;
        private ScaleTransform Scale
        {
            get
            {
                if (_scale == null)
                {
                    _scale = this.RenderTransform as ScaleTransform;
    
                    //RenderTransforms don't update the layout, so we need to trigger that ourselves:
                    _scale.Changed += (s, e) => { InvalidateArrange(); };
                }
                return _scale;
            }
        }
        protected override Size ArrangeOverride(Size arrangeBounds)
        {
            Statics.MessageIfDebug("Arranging");
            var layout = base.ArrangeOverride(arrangeBounds);
    
            var scale = this.Scale;
            if (scale != null)
            {
                //Because RenderTransforms don't update the layout,
                //we need to pretend we're bigger than we are to make room for our zoomed content:
                _extent = new Size(layout.Width * scale.ScaleX, layout.Height * scale.ScaleY);
                _viewport = layout;
    
                //Coerce offsets..
                var maxOffset = new Vector(ExtentWidth - ViewportWidth, ExtentHeight - ViewportHeight);
                _offset.X = Math.Max(0, Math.Min(_offset.X, maxOffset.X));
                _offset.Y = Math.Max(0, Math.Min(_offset.Y, maxOffset.Y));
    
                //..and move the zoomed content within the ScrollViewer:
                var renderOffsetX = (maxOffset.X > 0) ? (_offset.X / maxOffset.X) : 0.5;
                var renderOffsetY = (maxOffset.Y > 0) ? (_offset.Y / maxOffset.Y) : 0.5;
                this.RenderTransformOrigin = new Point(renderOffsetX, renderOffsetY);
    
                if (ScrollOwner != null)
                {
                    ScrollOwner.InvalidateScrollInfo();
                }
            }
    
            return layout;
        }
    
    
        #region IScrollInfo
    
        //This is the boilerplate IScrollInfo implementation, 
        //which can be found in *the first half* of this tutorial:
        //https://web.archive.org/web/20140809230047/http://tech.pro/tutorial/907/wpf-tutorial-implementing-iscrollinfo
        //(down to and including SetHorizontalOffset()/SetVerticalOffset()).
        //Note the bug reported by "Martin" in the comments.
    
        ...
    

    Usage:

    <TabItem Header="Content 1">
        <ScrollViewer CanContentScroll="True"
                      HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
                <v:ZoomableContentControl ContentTemplate="{StaticResource ContentTemplate1}" Margin="10" >
                <FrameworkElement.RenderTransform>
                    <StaticResource ResourceKey="ScaleTransform" />
                </FrameworkElement.RenderTransform>
            </v:ZoomableContentControl>
        </ScrollViewer>
    </TabItem>
    <TabItem Header="Content 2">
        <ScrollViewer CanContentScroll="True"
                      HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
            <v:ZoomableContentControl ContentTemplate="{StaticResource ContentTemplate2}" Margin="10" >
                <FrameworkElement.RenderTransform>
                    <StaticResource ResourceKey="ScaleTransform" />
                </FrameworkElement.RenderTransform>
            </v:ZoomableContentControl>
        </ScrollViewer>
    </TabItem>