Click here to Skip to main content
15,867,568 members
Articles / Desktop Programming / WPF

Scroll Synchronization

Rate me:
Please Sign up or sign in to vote.
4.94/5 (59 votes)
29 Jun 2013CPOL4 min read 167.2K   5.7K   73   43
Synchronizing the scroll position of multiple controls.

Image 1

Introduction

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 will see two ScrollViewers whose scroll positions are synchronized because they are both attached to the same ScrollGroup, "Group1":

XML
<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 or TreeViews, as long as they contain a ScrollViewer in their ControlTemplate.

You can see the Silverlight version of my synchronized ListBoxes at http://www.software-architects.com/devblog/2009/10/13/Scroll-Synchronization-in-WPF-and-Silverlight.

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

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 methods GetScrollGroup and SetScrollGroup to the class.

C#
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 every time a ScrollViewer 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 the name of the group that is set by the ScrollGroup property.

C#
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 fields horizontalScrollOffset and verticalScrollOffset. If so, we adjust the scroll position of the new ScrollViewer so that it matches the group. Otherwise, we add an entry to horizontalScrollOffset and verticalScrollOffset with the current scroll position of the new ScrollViewer. Finally, we add the new ScrollViewer to the scrollViewers 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 scroll position 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 a group, because when another ScrollViewer is added to that group later, we still know the last scroll position of that group.

C#
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 scroll position has changed, we update the dictionaries verticalScrollOffsets 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 their scroll positions.

C#
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.

XML
<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.

C#
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:

Image 2

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 ScrollGroup property. In this case, no changes are necessary for our ScrollSynchronizer.

XML
<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.

XML
<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" />

Image 3

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. At http://www.software-architects.at/TechnicalArticles/ScrollSync/tabid/101/language/en-US/Default.aspx, you can see an online demo of synchronized listboxes in Silverlight.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer software architects
Austria Austria
Hi, my name is Karin Huber. Since 1998 I have been working as a developer and IT consultant focusing on building database oriented web applications. In 2007 my friend Rainer and I decided that we want to build a business based on COTS (component off-the-shelf) software. As a result we founded "software architects".

These days we are offering our first version of the time tracking software called 'time cockpit'. You can find more information at www.timecockpit.com.

Comments and Discussions

 
QuestionOh my goddess!! Pin
sapFighter14-Jul-19 16:44
sapFighter14-Jul-19 16:44 
PraiseVote of 5 Pin
MonsieurPointer28-Jan-18 10:35
MonsieurPointer28-Jan-18 10:35 
Questionleaks memory ! Pin
ferkelrudolf14-Jun-17 0:46
ferkelrudolf14-Jun-17 0:46 
PraiseWorks great. Pin
SteffenHamann18-May-17 4:14
SteffenHamann18-May-17 4:14 
QuestionViewChanged vs ViewChanging for Real-Time Synchronization Pin
Nathan Sokalski22-Aug-16 10:20
Nathan Sokalski22-Aug-16 10:20 
QuestionGirl you saved my life... Pin
shrknt3515-Feb-16 19:34
shrknt3515-Feb-16 19:34 
GeneralMy vote of 5 Pin
AnibalVelarde15-Jan-16 18:30
AnibalVelarde15-Jan-16 18:30 
GeneralMy vote of 5 Pin
Member 1180326125-Sep-15 8:24
Member 1180326125-Sep-15 8:24 
QuestionIt doesn't work on RadGridView Pin
hosum010518-Mar-15 20:45
hosum010518-Mar-15 20:45 
QuestionOnly Synchronized Afterwards Pin
Nathan Sokalski8-Oct-14 15:25
Nathan Sokalski8-Oct-14 15:25 
I have not tested your exact code (because I develop apps rather than desktop), but your code has done a great job of introducing me to attached properties. I have developed my own version of ScrollSynchronizer which has options for both vertical and horizontal. Here is the exact code (it is in VB.NET rather than C#):

Public Class ScrollSynchronizer : Inherits DependencyObject
Public Shared ReadOnly VerticalScrollGroupProperty As DependencyProperty = DependencyProperty.RegisterAttached("VerticalScrollGroup", GetType(String), GetType(ScrollSynchronizer), New PropertyMetadata("", New PropertyChangedCallback(AddressOf VerticalScrollGroupChanged)))
Public Shared ReadOnly HorizontalScrollGroupProperty As DependencyProperty = DependencyProperty.RegisterAttached("HorizontalScrollGroup", GetType(String), GetType(ScrollSynchronizer), New PropertyMetadata("", New PropertyChangedCallback(AddressOf HorizontalScrollGroupChanged)))
Private Shared VerticalScrollViewers As New Dictionary(Of ScrollViewer, String)()
Private Shared HorizontalScrollViewers As New Dictionary(Of ScrollViewer, String)()
Private Shared VerticalScrollOffsets As New Dictionary(Of String, Double)()
Private Shared HorizontalScrollOffsets As New Dictionary(Of String, Double)()

Public Shared Sub SetVerticalScrollGroup(element As UIElement, value As String)
element.SetValue(VerticalScrollGroupProperty, value)
End Sub
Public Shared Function GetVerticalScrollGroup(element As UIElement) As String
Return element.GetValue(VerticalScrollGroupProperty).ToString()
End Function
Public Shared Sub SetHorizontalScrollGroup(element As UIElement, value As String)
element.SetValue(HorizontalScrollGroupProperty, value)
End Sub
Public Shared Function GetHorizontalScrollGroup(element As UIElement) As String
Return element.GetValue(HorizontalScrollGroupProperty).ToString()
End Function

Public Shared Sub VerticalScrollGroupChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
Dim sv As ScrollViewer = CType(d, ScrollViewer)
If sv IsNot Nothing Then
If Not String.IsNullOrEmpty(e.OldValue.ToString()) AndAlso VerticalScrollViewers.ContainsKey(sv) Then
RemoveHandler sv.ViewChanged, AddressOf VerticalScrollViewer_ViewChanged
VerticalScrollViewers.Remove(sv)
End If
If Not String.IsNullOrEmpty(e.NewValue.ToString()) Then
If VerticalScrollOffsets.Keys.Contains(e.NewValue.ToString()) Then : sv.ChangeView(Nothing, VerticalScrollOffsets(e.NewValue.ToString()), Nothing)
Else : VerticalScrollOffsets.Add(e.NewValue.ToString(), sv.VerticalOffset)
End If
VerticalScrollViewers.Add(sv, e.NewValue.ToString())
AddHandler sv.ViewChanged, AddressOf VerticalScrollViewer_ViewChanged
End If
End If
End Sub

Public Shared Sub HorizontalScrollGroupChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
Dim sv As ScrollViewer = CType(d, ScrollViewer)
If sv IsNot Nothing Then
If Not String.IsNullOrEmpty(e.OldValue.ToString()) AndAlso HorizontalScrollViewers.ContainsKey(sv) Then
RemoveHandler sv.ViewChanged, AddressOf HorizontalScrollViewer_ViewChanged
HorizontalScrollViewers.Remove(sv)
End If
If Not String.IsNullOrEmpty(e.NewValue.ToString()) Then
If HorizontalScrollOffsets.Keys.Contains(e.NewValue.ToString()) Then : sv.ChangeView(Nothing, HorizontalScrollOffsets(e.NewValue.ToString()), Nothing)
Else : HorizontalScrollOffsets.Add(e.NewValue.ToString(), sv.HorizontalOffset)
End If
HorizontalScrollViewers.Add(sv, e.NewValue.ToString())
AddHandler sv.ViewChanged, AddressOf HorizontalScrollViewer_ViewChanged
End If
End If
End Sub

Private Shared Sub VerticalScrollViewer_ViewChanged(sender As Object, e As ScrollViewerViewChangedEventArgs)
If Not e.IsIntermediate Then
Dim currsv As ScrollViewer = CType(sender, ScrollViewer)
Dim group As String = VerticalScrollViewers(currsv)
VerticalScrollOffsets(group) = currsv.VerticalOffset
For Each sv As KeyValuePair(Of ScrollViewer, String) In VerticalScrollViewers.Where(Function(s) s.Value = group AndAlso Not s.Key.Equals(currsv))
If sv.Key.VerticalOffset <> currsv.VerticalOffset Then sv.Key.ChangeView(Nothing, currsv.VerticalOffset, Nothing)
Next
End If
End Sub

Private Shared Sub HorizontalScrollViewer_ViewChanged(sender As Object, e As ScrollViewerViewChangedEventArgs)
If Not e.IsIntermediate Then
Dim currsv As ScrollViewer = CType(sender, ScrollViewer)
Dim group As String = HorizontalScrollViewers(currsv)
HorizontalScrollOffsets(group) = currsv.HorizontalOffset
For Each sv As KeyValuePair(Of ScrollViewer, String) In HorizontalScrollViewers.Where(Function(s) s.Value = group AndAlso Not s.Key.Equals(currsv))
If sv.Key.HorizontalOffset <> currsv.HorizontalOffset Then sv.Key.ChangeView(currsv.HorizontalOffset, Nothing, Nothing)
Next
End If
End Sub
End Class

My code has worked great, except for one thing: the synchronization does not happen until the scrolling is finished (when the user lifts their finger). I believe that this is related to the e.IsIntermediate condition in my ViewChanged events, but without this condition the ScrollViewers sort of vibrate as you scroll. I was wondering if you would be willing to look at my code and tell me if you have any ideas as to how to make the ScrollViewers scroll "together". I'm sure your code already does this, but I wasn't exactly sure what I needed to modify to make mine do it. Thank you for your great code, it has been a great example on learning attached properties, as well as being very useful itself. Thanks.
GeneralGreat work ! Pin
amigoface24-Jun-14 3:15
amigoface24-Jun-14 3:15 
GeneralPerfekt! Pin
da_adel30-Sep-13 0:09
da_adel30-Sep-13 0:09 
Questionsync up based on a column value? Pin
Harold Chattaway14-Jul-13 3:06
professionalHarold Chattaway14-Jul-13 3:06 
GeneralMy vote of 5 Pin
CodeHawkz20-Mar-13 0:16
CodeHawkz20-Mar-13 0:16 
QuestionGreat Work Pin
LUN@Work22-Nov-12 4:20
LUN@Work22-Nov-12 4:20 
AnswerMy vote of 5 Pin
To Thanh Liem25-Oct-12 4:15
To Thanh Liem25-Oct-12 4:15 
GeneralMy vote of 5 Pin
Jason McBratney5-Sep-12 16:12
Jason McBratney5-Sep-12 16:12 
Suggestionuse two attached properties Pin
William99813-Jun-12 17:29
William99813-Jun-12 17:29 
GeneralMy vote of 5 Pin
William99812-Jun-12 23:03
William99812-Jun-12 23:03 
QuestionScrollbar Help Pin
cpquest19-Mar-12 21:27
cpquest19-Mar-12 21:27 
QuestionMemory Leak Pin
Erlend Robaye26-Feb-12 23:34
Erlend Robaye26-Feb-12 23:34 
GeneralWorks on WPF4. Pin
shintwlv19-Jan-12 19:37
shintwlv19-Jan-12 19:37 
GeneralAppreciate Pin
SektorL17-Jun-11 0:44
SektorL17-Jun-11 0:44 
GeneralGreat Article...rocks Pin
Hiren Khirsaria2-Jun-11 23:45
professionalHiren Khirsaria2-Jun-11 23:45 
General5/5 works perfectly with DataGrid Pin
cl1m4x21-May-11 4:32
cl1m4x21-May-11 4:32 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.