Introduction
Imagine you have two ListBox
es with lots of items. Whenever a user scrolls in one of the two ListBox
es, 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 ScrollViewer
s 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 ListBox
es or TreeView
s, as long as they contain a ScrollViewer
in their ControlTemplate
.
You can see the Silverlight version of my synchronized ListBox
es 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.
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 ScrollViewer
s. But before we need some private fields to store all attached ScrollViewer
s 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.
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 ScrollViewer
s 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.
private static void OnScrollGroupChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
var scrollViewer = d as ScrollViewer;
if (scrollViewer != null)
{
if (!string.IsNullOrEmpty((string)e.OldValue))
{
if (scrollViewers.ContainsKey(scrollViewer))
{
scrollViewer.ScrollChanged -=
new ScrollChangedEventHandler(ScrollViewer_ScrollChanged);
scrollViewers.Remove(scrollViewer);
}
}
if (!string.IsNullOrEmpty((string)e.NewValue))
{
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);
}
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 ScrollViewer
s that are in the same group as the changed ScrollViewer
and update their 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 ScrollViewer
s. For both ScrollViewer
s, 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 TextBlock
s to both panels, so that the ScrollBar
s will get visible.
public Window1()
{
InitializeComponent();
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 ScrollViewer
s:
Now, how can we get other controls synchronized? Let's replace the ScrollViewer
s by two ListBox
es. Unfortunately, we cannot set the attached property ScrollGroup
to the ListBox
es. 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 ListBox
es, that sets the ScrollGroup
property. In this case, no changes are necessary for our 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 ScrollViewer
s 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" />
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 ScrollBar
s. 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.