滚动同步






4.94/5 (58投票s)
同步多个控件的滚动位置。
引言
想象一下,您有两个包含大量项目的ListBox
。当用户滚动其中一个 ListBox
时,另一个也应该被更新。本文的目标是创建一个简单的附加属性,允许我们对可滚动控件进行分组。在下面的示例中,您将看到两个 ScrollViewer
,它们的滚动位置是同步的,因为它们都附加到同一个 ScrollGroup
,“Group1
”。
<ScrollViewer
Name="ScrollViewer1"
scroll:ScrollSynchronizer.ScrollGroup="Group1">
...
</ScrollViewer>
<ScrollViewer
Name="ScrollViewer2"
scroll:ScrollSynchronizer.ScrollGroup="Group1">
...
</ScrollViewer>
由于大多数可滚动控件在其模板中使用 ScrollViewer
来实现滚动,因此只要它们在其 ControlTemplate
中包含 ScrollViewer
,此方法也适用于 ListBox
或 TreeView
等其他控件。
您可以在 http://www.software-architects.com/devblog/2009/10/13/Scroll-Synchronization-in-WPF-and-Silverlight 找到我的同步 ListBox
的 Silverlight 版本。
在接下来的文章中,我将展示如何在 WPF 中构建 ScrollSyncronizer
类,以同步各种可滚动控件的滚动位置。在源代码下载中,您将找到 WPF 和 Silverlight 的工作解决方案。
构建 ScrollSynchronizer
我们的 ScrollSynchronizer
对象在 UI 中没有表示。它只负责提供附加属性 ScrollGroup
。因此,我选择 DependencyObject
作为基类。首先,我向类添加了附加依赖属性 ScrollGroup
及其相应的 GetScrollGroup
和 SetScrollGroup
方法。
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);
}
...
}
在新属性的属性元数据中,有一个回调,每次 ScrollViewer
使用附加属性时都会调用它。所以,这里是我们提供逻辑以将 ScrollViewer
与所有其他附加的 ScrollViewer
同步的地方。但在那之前,我们需要一些私有字段来存储所有附加的 ScrollViewer
以及它们相应的水平和垂直偏移量。这些字典中的字符串部分是由 ScrollGroup
属性设置的组名。
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>();
现在,我们可以实现 ScrollGroup
属性更改的回调。基本上,代码很简单。当通过设置附加属性添加新的 ScrollViewer
时,我们会检查是否已在 horizontalScrollOffset
和 verticalScrollOffset
字段中找到该组的滚动位置。如果是,我们调整新 ScrollViewer
的滚动位置,使其与组匹配。否则,我们在 horizontalScrollOffset
和 verticalScrollOffset
中添加一个条目,其中包含新 ScrollViewer
的当前滚动位置。最后,我们将新 ScrollViewer
添加到 scrollViewers
字典中,并附带其组名,然后添加一个事件处理程序来处理 ScrollChanged
事件,以便在滚动位置发生更改时,我们可以调整组中的所有其他 ScrollViewer
。
如果删除了附加属性,我们会从列表中删除 ScrollViewer
。在这种情况下,我们不会删除 horizontalScrollOffset
和 verticalScrollOffset
中的条目,即使它是组中的最后一个 ScrollViewer
,因为当稍后将另一个 ScrollViewer
添加到该组时,我们仍然知道该组的最后一个滚动位置。
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);
}
}
}
现在,我们的最后一个任务是实现 ScrollChanged
事件的处理程序。如果水平或垂直滚动位置发生变化,我们会将 verticalScrollOffsets
和 horizontalScrollOffsets
字典更新到最新位置。然后,我们需要找到与更改的 ScrollViewer
属于同一组的所有 ScrollViewer
,并更新它们的滚动位置。
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);
}
}
}
测试 ScrollSynchronizer
为了测试新的附加属性,我们构建了一个包含两个 ScrollViewer
的简单 UI。对于这两个 ScrollViewer
,我们将 ScrollGroup
属性设置为“Group1
”。
<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>
在代码隐藏文件中,我们在两个面板中添加了一些 TextBlock
,以便 ScrollBar
可以显示出来。
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) });
}
}
完成!我们有两个同步的 ScrollViewer
。
同步 ListBox
那么,我们如何才能同步其他控件呢?让我们用两个 ListBox
替换 ScrollViewer
。不幸的是,我们不能将附加属性 ScrollGroup
设置给 ListBox
。在 OnScrollGroupChanged
回调中,我们假设我们总是会得到一个 ScrollViewer
。因此,我们可以增强 ScrollSynchronizer
来接受其他类型的控件,或者我们可以简单地为 ListBox
中的 ScrollViewer
添加一个样式,该样式会设置 ScrollGroup
属性。在这种情况下,我们的 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>
更好的方法是在 Grid
资源中设置样式,这样它就可以自动应用于网格中的所有 ScrollViewer
。
<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 支持
基本上,这个解决方案也适用于 Silverlight。具体来说,存在一些差异,例如 Silverlight 中的 ScrollViewer
不提供 ScrollChanged
事件。但是,您可以通过使用底层 ScrollBar
的 Scroll
和 ValueChanged
事件来绕过这个问题。另一个问题是,即使使用 ImplicitStyleManager
,Style
对于 ListBox
示例中的 ScrollViewer
也未应用。因此,我最终为 Silverlight 在代码中设置了附加属性。在源代码下载中,您将找到 WPF 和 Silverlight 的工作解决方案。在 http://www.software-architects.at/TechnicalArticles/ScrollSync/tabid/101/language/en-US/Default.aspx,您可以查看 Silverlight 同步 Listbox 的在线演示。