Scroll Synchronization in WPF and Silverlight

Karin Huber Tuesday, October 13, 2009 by Karin Huber

Imagine you have two ListBoxes with lots of items. Whenever a user scrolls in one of the two ListBoxes, the other one should be updated, too. What we want to do in this article is to create a simple attached property, that allows us to group scrollable controls. In the following sample you see two ScrollViewers, whose scroll positions are synchronized because they are both attached to the same ScrollGroup "Group1":

<ScrollViewer 
Name="ScrollViewer1" scroll:ScrollSynchronizer.ScrollGroup="Group1">
   ...
</ScrollViewer>
  
<ScrollViewer 
Name="ScrollViewer2" scroll:ScrollSynchronizer.ScrollGroup="Group1">
   ...
</ScrollViewer>

As most scrollable controls use the ScrollViewer in their template to enable scrolling, this should also work for other controls like ListBoxes of TreeViews, as long as they contain aScrollViewer in their ControlTemplate.

Online Silverlight Demo

Here you can see the Silverlight version of my synchronized ListBoxes:

In the following article I will show, how to build the ScrollSyncronizer class in WPF to synchronize the scroll position of various scrollable controls. In the source code download you will find a working solution for WPF and Silverlight.

  • Building the ScrollSynchronizer
  • Testing the ScrollSynchronizer
  • Synchronizing ListBoxes
  • Silverlight Support

Building the ScrollSynchronizer

Our ScrollSynchronizer object has no representation in the UI. It is just responsible for providing the attached property ScrollGroup. So I have chosen DependencyObject as the base class. First I added the attached dependency property ScrollGroup with its corresponding methodes GetScrollGroup and SetScrollGroup to the class.

public class ScrollSynchronizer : DependencyObject
{
    public static readonly DependencyProperty ScrollGroupProperty =
        DependencyProperty.RegisterAttached(
        "ScrollGroup", 
        typeof(string), 
        typeof(ScrollSynchronizer), 
        new PropertyMetadata(new PropertyChangedCallback(
            OnScrollGroupChanged)));

    public static void SetScrollGroup(
        DependencyObject obj, 
        string scrollGroup)
    {
        obj.SetValue(ScrollGroupProperty, scrollGroup);
    }

    public static string GetScrollGroup(DependencyObject obj)
    {
        return (string)obj.GetValue(ScrollGroupProperty);
    }

    ...
}

In the property metadata of the new property there is a callback that is invoked everytime aScrollViewer uses the attached property, so this is the place where we will provide the logic to synchronize the ScrollViewer with all other attached ScrollViewers. But before we need some private fields to store all attached ScrollViewers as well as their corresponding horizontal and vertical offsets. The string part in these dictionaries is equal to the name of the group that is set by the ScrollGroup property.

private static Dictionary<ScrollViewer, string> scrollViewers = 
    new Dictionary<ScrollViewer, string>();

private static Dictionary<string, double> horizontalScrollOffsets = 
    new Dictionary<string, double>();

private static Dictionary<string, double> verticalScrollOffsets = 
    new Dictionary<string, double>();

Now we can implement the callback for changes in the ScrollGroup property. Basically the code is quite simple. When a new ScrollViewer is added by setting the attached property, we check if we can already find a scroll position for the group in the fieldshorizontalScrollOffset and verticalScrollOffset. If so, we set the adjust the scollposition of the new ScrollViewer, so that it matches the group. Otherwise we add an entry to horizontalScrollOffset and verticalScrollOffset with the current scrollposition of the new ScrollViewer. Finally we add the new ScrollViewer to thescrollViewers dictionary with its corresponding group name, and we add an event handler for the ScrollChanged event, so that we can adapt all other ScrollViewers in the group when the scrollposition has changed.

If the attached property is removed we remove the ScrollViewer from the list. In this case we do not remove the entries in horizontalScrollOffset and verticalScrollOffset, even when it is the last ScrollViewer of one group, because when another ScrollViewer is added to that group later, we still know the last scrollposition of that group.

private static void OnScrollGroupChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 
{ 
    var scrollViewer = d as ScrollViewer; 
    if (scrollViewer != null) 
    { 
        if (!string.IsNullOrEmpty((string)e.OldValue)) 
        { 
            // Remove scrollviewer 
            if (scrollViewers.ContainsKey(scrollViewer)) 
            { 
                scrollViewer.ScrollChanged -=  
                    new ScrollChangedEventHandler( 
                    ScrollViewer_ScrollChanged); 
                scrollViewers.Remove(scrollViewer); 
            } 
        } 

        if (!string.IsNullOrEmpty((string)e.NewValue)) 
        { 
            // If group already exists, set scrollposition of  
            // new scrollviewer to the scrollposition of the group 
            if (horizontalScrollOffsets.Keys.Contains((string)e.NewValue)) 
            { 
                scrollViewer.ScrollToHorizontalOffset( 
                    horizontalScrollOffsets[(string)e.NewValue]); 
            } 
            else 
            { 
                horizontalScrollOffsets.Add( 
                    (string)e.NewValue,  
                    scrollViewer.HorizontalOffset); 
            } 

            if (verticalScrollOffsets.Keys.Contains((string)e.NewValue)) 
            { 
                scrollViewer.ScrollToVerticalOffset( 
                    verticalScrollOffsets[(string)e.NewValue]); 
            } 
            else 
            { 
                verticalScrollOffsets.Add( 
                    (string)e.NewValue,  
                    scrollViewer.VerticalOffset); 
            } 

            // Add scrollviewer 
            scrollViewers.Add(scrollViewer, (string)e.NewValue); 
            scrollViewer.ScrollChanged +=  
                new ScrollChangedEventHandler(ScrollViewer_ScrollChanged); 
        } 
    } 
}

Now our last task is to implement the event handler for the ScrollChanged event. If the horizontal or the vertical scrollposition has changed, we update the dictionariesverticalScrollOffsets and horizontalScrollOffsets to the latest position. Then we have to find all ScrollViewers that are in the same group as the changed ScrollViewer and update its scroll positions.

private static void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) 
{ 
    if (e.VerticalChange != 0 || e.HorizontalChange != 0) 
    { 
        var changedScrollViewer = sender as ScrollViewer; 
        Scroll(changedScrollViewer); 
    } 
} 

private static void Scroll(ScrollViewer changedScrollViewer) 
{ 
    var group = scrollViewers[changedScrollViewer]; 
    verticalScrollOffsets[group] = changedScrollViewer.VerticalOffset; 
    horizontalScrollOffsets[group] = changedScrollViewer.HorizontalOffset; 

    foreach (var scrollViewer in scrollViewers.Where( 
        (s) => s.Value == group && s.Key != changedScrollViewer)) 
    { 
        if (scrollViewer.Key.VerticalOffset !=  
            changedScrollViewer.VerticalOffset) 
        { 
            scrollViewer.Key.ScrollToVerticalOffset( 
                changedScrollViewer.VerticalOffset); 
        } 

        if (scrollViewer.Key.HorizontalOffset !=  
            changedScrollViewer.HorizontalOffset) 
        { 
            scrollViewer.Key.ScrollToHorizontalOffset( 
                changedScrollViewer.HorizontalOffset); 
        } 
    } 
}

Testing the ScrollSynchronizer

To test the new attached property we build a simple UI with two ScrollViewers. For both ScrollViewers we assign the value "Group1" to the ScrollGroup property.

<Window  
xmlns:scroll="clr-namespace:SoftwareArchitects.Windows.Controls; 
assembly=SoftwareArchitects.Windows.Controls.ScrollSynchronizer" 
...> 
    <Grid Margin="10"> 
  
        <Grid.ColumnDefinitions> 
            <ColumnDefinition Width="*" /> 
            <ColumnDefinition Width="*" /> 
        </Grid.ColumnDefinitions> 
   
        <ScrollViewer Grid.Column="0" Name="ScrollViewer1"  
        Margin="0,0,5,0" scroll:ScrollSynchronizer.ScrollGroup="Group1"> 
            <StackPanel Name="Panel1" /> 
        </ScrollViewer> 
   
        <ScrollViewer Grid.Column="1" Name="ScrollViewer2"  
        Margin="5,0,0,0" scroll:ScrollSynchronizer.ScrollGroup="Group1"> 
            <StackPanel Name="Panel2" /> 
        </ScrollViewer> 
    </Grid> 
</Window>

In the code-behind file we add some TextBlocks to both panels, so that the ScrollBars will get visible.

public Window1() 
{ 
    InitializeComponent(); 

    // Fill listboxes 
    for (var i = 0; i < 100; i++) 
    { 
        this.Panel1.Children.Add(new TextBlock()  
            { Text = string.Format("This is item {0}", i) }); 
        this.Panel2.Children.Add(new TextBlock()  
            { Text = string.Format("This is item {0}", i) }); 
    } 
}

Done! We have two synchronized ScrollViewers:

Synchronizing ListBoxes

Now, how can we get other controls synchronized? Let's replace the ScrollViewers by two ListBoxes. Unfortunately we cannot set the attached property ScrollGroup to the ListBoxes. In the OnScrollGroupChanged callback we assume, that we will always get a ScrollViewer. So we could enhance the ScrollSynchronizer to accept other types of controls, or we could simply add a style for the ScrollViewer within the ListBoxes, that sets the ScrollGroupproperty. In this case no changes are necessary for out ScrollSynchronizer.

<ListBox Grid.Column="0" Name="ListBox1" Margin="0,0,5,0"> 
    <ListBox.Resources> 
        <Style TargetType="ScrollViewer"> 
            <Setter Property="scroll:ScrollSynchronizer.ScrollGroup"  
                Value="Group1" /> 
        </Style> 
    </ListBox.Resources> 
</ListBox> 

<ListBox Grid.Column="1" Name="ListBox2" Margin="5,0,0,0"> 
    <ListBox.Resources> 
        <Style TargetType="ScrollViewer"> 
            <Setter Property="scroll:ScrollSynchronizer.ScrollGroup"  
                Value="Group1" /> 
        </Style> 
    </ListBox.Resources> 
</ListBox>

A nicer way to do this would be to set the style in the Grid  resources, so it applies to all ScrollViewers in the grid automatically.

<Grid.Resources> 
    <Style TargetType="ScrollViewer"> 
        <Setter Property="scroll:ScrollSynchronizer.ScrollGroup"  
            Value="Group1" /> 
    </Style> 
</Grid.Resources> 

<ListBox Grid.Column="0" Name="ListBox1" Margin="0,0,5,0" /> 

<ListBox Grid.Column="1" Name="ListBox2" Margin="5,0,0,0" />

Silverlight Support

Basically this solution would also work for Silverlight. In detail there are some differences like a ScrollViewer does not provide the ScrollChanged event in Silverlight. But you can bypass this problem by using the Scroll and ValueChanged events of the underlying ScrollBars. Another problem is that the Style for the ScrollViewer is not applied in the ListBox sample, even when using the ImplicitStyleManager. So I ended up setting the attached property in code for Silverlight. In the source code download you will find a working solution for WPF and Silverlight.


comments powered by Disqus