Search code examples
wpfwindowscrollbarscrollviewer

Swap vertical ScrollBar side based on window position on screen


I have a window that contains a ScrollViewer, What i want is to swap the vertical ScrollBar side to the left if the window is on the right side of the screen and vice-versa.

Here is my current ScrollViewer template in a ResourceDictionary:

<Style x:Key="ScrollViewerWithoutCollapsedVerticalScrollBar" TargetType="{x:Type ScrollViewer}">
    <Setter Property="OverridesDefaultStyle" Value="True" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ScrollViewer}">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition />
                        <ColumnDefinition Width="Auto" />
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition />
                        <RowDefinition Height="Auto" />
                    </Grid.RowDefinitions>
                    <Border Grid.Column="0" BorderThickness="0">
                        <ScrollContentPresenter />
                    </Border>
                    <ScrollBar x:Name="PART_VerticalScrollBar"
                               Grid.Column="1"
                               Value="{TemplateBinding VerticalOffset}"
                               Maximum="{TemplateBinding ScrollableHeight}"
                               ViewportSize="{TemplateBinding ViewportHeight}"
                               Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility, Converter={StaticResource ComputedScrollBarVisibilityWithoutCollapse}}" />
                    <ScrollBar x:Name="PART_HorizontalScrollBar"
                               Orientation="Horizontal"
                               Grid.Row="1"
                               Grid.Column="0"
                               Value="{TemplateBinding HorizontalOffset}"
                               Maximum="{TemplateBinding ScrollableWidth}"
                               ViewportSize="{TemplateBinding ViewportWidth}"
                               Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}" />
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

What would be the way to go on from there?


Solution

  • Positioning the ScrollBar inside of your ScrollViewer depending on the window position requires you to know:

    • The Size of the containing window to know where the center is
    • The window location to determine whether it crossed the center of the screen or not
    • The screen size to know where the center of the screen even is

    What makes it addionally difficult are the following factors

    • You need to get the information from your ScrollViewer that is a child of the window that could change
    • Size changes and location changes can also change the side of the screen
    • Screen sizes are difficult to get in WPF, especially in multi-monitor setups

    I will show you a working example to achieve what you want for the primary screen. Since this is another block for a another question, you can start from there and adapt it to your requirements.

    In order to tackle the issues above, we will use the SizeChanged and LocationChanged events to detect changes of a window in size and location. We will use the SystemParameters.PrimaryScreenWidth to get the screen width, which can, but may not work in multi-monitor setups with different resolutions.

    Your control will alter the default ScrollViewer behavior and appearance. I think it is best to create a custom control to make it reusable, because dealing with this in XAML with other techniques might become messy.

    Create the custom scroll viewer

    Create a new type AdaptingScrollViewer that inherits from ScrollViewer like below. I have commented the code for you to explain how it works.

    public class AdaptingScrollViewer : ScrollViewer
    {
       // We need this dependency property internally, so that we can bind the parent window
       // and get notified when it changes 
       private static readonly DependencyProperty ContainingWindowProperty =
          DependencyProperty.Register(nameof(ContainingWindow), typeof(Window),
             typeof(AdaptingScrollViewer), new PropertyMetadata(null, OnContainingWindowChanged));
    
       // Getter and setter for the dependency property value for convenient access
       public Window ContainingWindow
       {
          get => (Window)GetValue(ContainingWindowProperty);
          set => SetValue(ContainingWindowProperty, value);
       }
    
       static AdaptingScrollViewer()
       {
          // We have to override the default style key, so that we can apply our new style
          // and control template to it
          DefaultStyleKeyProperty.OverrideMetadata(typeof(AdaptingScrollViewer),
             new FrameworkPropertyMetadata(typeof(AdaptingScrollViewer)));
       }
    
       public AdaptingScrollViewer()
       {
          // Relative source binding to the parent window
          BindingOperations.SetBinding(this, ContainingWindowProperty,
             new Binding { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Window), 1) });
    
          // When the control is removed, we want to clean up and remove the event handlers
          Unloaded += OnUnloaded;
       }
    
       private void OnUnloaded(object sender, RoutedEventArgs e)
       {
          RemoveEventHandlers(ContainingWindow);
       }
    
       // This method is called when the window in the relative source binding changes
       private static void OnContainingWindowChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
       {
          var scrollViewer = (AdaptingScrollViewer)d;
          var oldContainingWindow = (Window)e.OldValue;
          var newContainingWindow = (Window)e.NewValue;
    
          // If the scroll viewer got detached from the current window and attached to a new
          // window, remove the previous event handlers and add them to the new window
          scrollViewer.RemoveEventHandlers(oldContainingWindow);
          scrollViewer.AddEventHandlers(newContainingWindow);
       }
    
       private void AddEventHandlers(Window window)
       {
          if (window == null)
             return;
    
          // Add events to react to changes of the window size and location
          window.SizeChanged += OnSizeChanged;
          window.LocationChanged += OnLocationChanged;
    
          // When we add new event handlers, then adapt the scrollbar position immediately
          SetScrollBarColumn();
       }
    
       private void RemoveEventHandlers(Window window)
       {
          if (window == null)
             return;
    
          // Remove the event handlers to prevent memory leaks
          window.SizeChanged -= OnSizeChanged;
          window.LocationChanged -= OnLocationChanged;
       }
    
       private void OnSizeChanged(object sender, SizeChangedEventArgs e)
       {
          SetScrollBarColumn();
       }
    
       private void OnLocationChanged(object sender, EventArgs e)
       {
          SetScrollBarColumn();
       }
    
       private void SetScrollBarColumn()
       {
          if (ContainingWindow == null)
             return;
    
    
          // Get the column in the control template grid depending on the center of the screen
          var column = ContainingWindow.Left <= GetHorizontalCenterOfScreen(ContainingWindow) ? 0 : 2;
    
          // The scrollbar is part of our control template, so we can get it like this
          var scrollBar = GetTemplateChild("PART_VerticalScrollBar");
    
          // If someone overwrote our control template and did not add a scrollbar, ignore
          // it instead of crashing the application, because everybody makes mistakes sometimes
          scrollBar?.SetValue(Grid.ColumnProperty, column);
       }
    
       private static double GetHorizontalCenterOfScreen(Window window)
       {
          return SystemParameters.PrimaryScreenWidth / 2 - window.Width / 2;
       }
    }
    

    Creating a control template

    Now our new AdaptingScrollViewer nees a control template. I took your example and adapted the style and control template and commented the changes, too.

    <!-- Target the style to our new type and base it on scroll viewer to get default properties -->
    <Style x:Key="AdaptingScrollViewerStyle"
                    TargetType="{x:Type local:AdaptingScrollViewer}"
                    BasedOn="{StaticResource {x:Type ScrollViewer}}">
       <Setter Property="Template">
          <Setter.Value>
             <!-- The control template must also target the new type -->
             <ControlTemplate TargetType="{x:Type local:AdaptingScrollViewer}">
                <Grid>
                   <Grid.ColumnDefinitions>
                      <!-- Added a new column for the left position of the scrollbar -->
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition />
                      <ColumnDefinition Width="Auto"/>
                   </Grid.ColumnDefinitions>
                   <Grid.RowDefinitions>
                      <RowDefinition/>
                      <RowDefinition Height="Auto"/>
                   </Grid.RowDefinitions>
                   <Border Grid.Column="1" BorderThickness="0">
                      <ScrollContentPresenter/>
                   </Border>
                   <ScrollBar x:Name="PART_VerticalScrollBar"
                                       Grid.Row="0"
                                       Grid.Column="2"
                                       Value="{TemplateBinding VerticalOffset}"
                                       Maximum="{TemplateBinding ScrollableHeight}"
                                       ViewportSize="{TemplateBinding ViewportHeight}"/>
                   <!-- Added a column span to correct the horizontal scroll bar -->
                   <ScrollBar x:Name="PART_HorizontalScrollBar"
                                       Orientation="Horizontal"
                                       Grid.Row="1"
                                       Grid.Column="0"
                                       Grid.ColumnSpan="3"
                                       Value="{TemplateBinding HorizontalOffset}"
                                       Maximum="{TemplateBinding ScrollableWidth}"
                                       ViewportSize="{TemplateBinding ViewportWidth}"/>
                </Grid>
             </ControlTemplate>
          </Setter.Value>
       </Setter>
    </Style>
    

    You need to add the following style after the style above in your resource dictionary, too, so that the AdaptingScrollViewer get styled automatically.

    <Style TargetType="{x:Type local:AdaptingScrollViewer}" BasedOn="{StaticResource AdaptingScrollViewerStyle}"/>
    

    Showing the result

    In in your main XAML create the control like this to enable both scrollbars and see the result.

    <local:AdaptingScrollViewer HorizontalScrollBarVisibility="Visible"
                                VerticalScrollBarVisibility="Visible"/>