在Part 2中翻译了Ben Constable关于实现IScrollInfo的一个专题,在这篇中我们继续翻译Dan Crevier的4篇文章,看看他是如何来玩转UI Virtualization的。
显示大量数据时会对性能带来很大的挑战。如果你使用带滚动条的list来显示数据,一个提升性能的方式是仅仅只显示那些看得到的UI elements。这需要用到UI virtualization(相对于data virtualization来说,data virtualization则是只提供当前看得到的数据,UI virtualization中数据已经完全就位) 。WPF已经内置了VirtualizingStackPanel支持UI virtualization。ListBox将VSP作为默认的ItemsPanel。当然,如果你希望改变children布局时,你需要自己写Panel来支持到virtualization。WPF提供了VirtualizingPanel,你可以从这个类继承,但还是有相当多的工作需要你来完成。我会在这个系列的post中描述如何写your own virtualizing panel。
基本的思路如下:
听上去很简单吧?不过还是还有一定的复杂度。
第一个问题在于MeasureCore如何获知哪些内容是可见的。如果你只是将子类化的VirtualizingPanel放到ItemsControl中,你会发现你在measure时获得的可用区域是无限大,你根本无法知道哪些是可见的。正确的方式是让Panel实现IScrollInfo。Ben Constable已经写了一系列post很好的说明了IScrollInfo,所以我在这里就不展开关于如何实现IScrollInfo的内容了。你一旦实现了IScrollInfo,那么MeasureCore获得的可用区域就会是可见区域。
第二个问题是你需要有一个合适方式去创建对应的UI elements,这个方式需要兼容PWF的data model手法(根据data items找到对应的DataTemplate,etc.)。Beatriz Costa有一组很不错的blog关于在WPF中如何处理data相关内容。VirtualizingPanel提供了一个实现IItemContainerGenerator接口的对象,供你来创建UI elements。
最后一个问题就是你如何跟踪data item和UI elements对应关系。IItemContainerGenerator可以帮助你来完成这个跟踪过程。同时,VirtualizingPanel提供了insert/add/remove方法来管理UI element。
在part 2中,我们会深入IItemContainerGenerator,解释它是如何来创建UI element以及如何映射data item和UI elment间的关系。
在part 1中我给出一个自定义VirtualizingPanel的大体思路。对IItemContainerGenerator的理解是一个比较关键的问题。我个人发现这个问题上手不是那么直观。IITemContainerGenerator不仅可以帮助我们生成(realize)和销毁(virtualize) item,此外我们还需通过GeneratorPosition来trackchild和item之间的索引关系。
我们通过一个example来看一下它是如何工作的。这里example中,我将创建一个WPF application然后在ItemsControl中加入5个integer,在这个ItemsControl中采用VirtualizingPanel之类作为其中ItemsPanel。然后我们在MeasureOverride中加入一些代码来测试。
在使用generator之前,我们必须先访问InternalChildren,否则generator会是null。这是一个bug,希望之后能够fix掉。
UIElementCollection children = base.InternalChildren;
接下来,我们通过一个unitlity function来看一下generator的状态。下面的代码中演示了如何使用GeneratorPositionFromIndex。它需要一个item index作为参数,注意并不是child index。所以,在我们这个测试中,循环的数量始终是5次,这个次数和有多少个children被创建是无关的。
private void DumpGeneratorContent() {
IItemContainerGenerator generator = this.ItemContainerGenerator;
ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
Console.WriteLine("Generator positions:");
for (int i = 0; i < itemsControl.Items.Count; i++) {
GeneratorPosition position = generator.GeneratorPositionFromIndex(i);
Console.WriteLine("Item index=" + i + ", Generator position: index=" + position.Index + ", offset=" + position.Offset);
}
Console.WriteLine();
}
通过上述代码可以看到初始状态情况,这个时候没有realize任何item – 他们现在都是virtualized。
Generator positions: Item index=0, Generator position: index=-1, offset=1 Item index=1, Generator position: index=-1, offset=2 Item index=2, Generator position: index=-1, offset=3 Item index=3, Generator position: index=-1, offset=4 Item index=4, Generator position: index=-1, offset=5
译注:补充一下细节
<Window.Resources>
<Style x:Key="ItemsControlStyle" TargetType="ItemsControl">
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<local:VirtualizingTilePanel />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<ItemsControl Style="{StaticResource ItemsControlStyle}">
<ListBoxItem>1</ListBoxItem>
<ListBoxItem>2</ListBoxItem>
<ListBoxItem>3</ListBoxItem>
<ListBoxItem>4</ListBoxItem>
<ListBoxItem>5</ListBoxItem>
</ItemsControl>
</Grid>
public class VirtualizingTilePanel : VirtualizingPanel {
protected override Size MeasureOverride(Size availableSize) {
UIElementCollection children = base.InternalChildren;
DumpGeneratorContent();
return new Size();
}
private void DumpGeneratorContent() {
IItemContainerGenerator generator = this.ItemContainerGenerator;
ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
Console.WriteLine("Generator positions:");
for (int i = 0; i < itemsControl.Items.Count; i++) {
GeneratorPosition position = generator.GeneratorPositionFromIndex(i);
Console.WriteLine("Item index=" + i + ", Generator position: index=" + position.Index + ", offset=" + position.Offset);
}
Console.WriteLine();
}
}
下面我们来realize第一个item:
Console.WriteLine("Realizing the first item...");
IItemContainerGenerator generator = this.ItemContainerGenerator;
bool isNewlyRealized;
GeneratorPosition position = generator.GeneratorPositionFromIndex(0);
using (generator.StartAt(position, GeneratorDirection.Forward, true)) {
DependencyObject child = generator.GenerateNext(out isNewlyRealized);
Console.WriteLine("isNewlyRealized = " + isNewlyRealized);
generator.PrepareItemContainer(child);
}
DumpGeneratorContent();
首先我们需要将item index对应到GeneratorPosition上,在生成之前,需要通过StartAt告知generator从哪里开始生成。StartAt是一个IDisposable对象,所以我们将它放到一个using块中。我们在StartAt中还需要指定方向,这里我们向前生成。最后一个参数allowStartAtRealizedItem意思是当对应position的item已经realize时StartAt会抛出exception。在这个case中,还没有任何item realize,所以我们不管它。
真正创建child的方法是GenerateNext()。它有一个output parameter,这个值能告诉你这个item是这次新创建还是之前已经创建的。最后,当child被创建出来后,你必须调用PrepareItemContainer()建立相应UI。坦白说,我不确定这是否是必须的。看起来GenerateNext可以帮我们做掉这件事。
译注:根据source code,你可以发现GenerateNext仅返回ListBoxItem,其中没有对应的DataTemplate,调用PrepareItemContainer会redirect到ItemsContainer的PrepareItemContainer,在这个时候套上相应的DataTemplate和Style,可能为了方法职责清晰将2者分开了。
输出内容如下:
Realizing the first item... isNewlyRealized = True Generator positions: Item index=0, Generator position: index=0, offset=0 Item index=1, Generator position: index=0, offset=1 Item index=2, Generator position: index=0, offset=2 Item index=3, Generator position: index=0, offset=3 Item index=4, Generator position: index=0, offset=4
注意一下这里我们只realize了index 0 and offset 0,其他的items还是处于virtualize状态。
如果你重复上面的代码,你会得到isNewlyRealized =false。如果你在StartAt最后一个参数传入false,你会得到exception,因为这个item之前已经realized。
这个时候如果你realize第三个item,GeneratorPositionFromIndex对应的index是2。
Console.WriteLine("Realizing the 3rd item...");
position = generator.GeneratorPositionFromIndex(2);
using (generator.StartAt(position, GeneratorDirection.Forward, true)) {
DependencyObject child = generator.GenerateNext(out isNewlyRealized);
Console.WriteLine("isNewlyRealized = " + isNewlyRealized);
generator.PrepareItemContainer(child);
}
DumpGeneratorContent();
输出内容如下:
Realizing the third item... isNewlyRealized = True Generator positions: Item index=0, Generator position: index=0, offset=0 Item index=1, Generator position: index=0, offset=1 Item index=2, Generator position: index=1, offset=0 Item index=3, Generator position: index=1, offset=1 Item index=4, Generator position: index=1, offset=2
你可以发现item 2对应的GeneratorPostion这时index=1 and offset=0。简单来说,如果我们根据item index对children进行排序,那么generator的position也是同样对应到child的索引。
你可以用下面的代码revirtualize一个item:
Console.WriteLine("Virtualizng the first item...");
position = generator.GeneratorPositionFromIndex(0);
generator.Remove(position, 1);
DumpGeneratorContent();
我们通过item index得到相应的GeneratorPosition,然后从这个position处移除1个item,如果你需要一次移除多个item,那些移除的item必须是连续realize的。输出内容如下:
Virtualizng the first item... Generator positions: Item index=0, Generator position: index=-1, offset=1 Item index=1, Generator position: index=-1, offset=2 Item index=2, Generator position: index=0, offset=0 Item index=3, Generator position: index=0, offset=1 Item index=4, Generator position: index=0, offset=2
index=2的item对应的GeneratorPosition由1变为0。
总结一下:
在下篇中,我们会来实现VirtualizingPanel的MeasureOverride。同时也会谈谈关于在collection发生变化时我们该怎么办。
译注:这里配上MSDN对GeneratorPosition中index和offset的说明,
An Int32 index that is relative to the generated (realized) items. -1 is a special value that refers to a fictitious item at the beginning or the end of the items list.
An Int32 offset that is relative to the ungenerated (unrealized) items near the indexed item. An offset of 0 refers to the indexed element itself, an offset 1 refers to the next ungenerated (unrealized) item, and an offset of -1 refers to the previous item.
在我们已经了解IItemContainerGenerator之后,我们继续来看如何实现virutualizing panel的MeasureOverride。我们还是通过代码来看how it works。下面的代码,没有包括IScrollInfo的内容以及Layout相关的内容,基本上来说,我主要想说一下如何根据滚动信息来找出哪些item需要显示。
protected override Size MeasureOverride(Size desiredSize) {
// Do work for IScrollInfo implementation
// Figure out range that's visible based on layout algorithm
int firstVisibleItemIndex = 0;
int lastVisibleItemIndex = 0;
// We need to access InternalChildren before the generator to work around a bug
UIElementCollection children = this.InternalChildren;
IItemContainerGenerator generator = this.ItemContainerGenerator;
// Get the generator position of the first visible data item
GeneratorPosition startPos = generator.GeneratorPositionFromIndex(firstVisibleItemIndex);
// Get index where we'd insert the child for this position. If the item is realized
// (position.Offset == 0), it's just position.Index, otherwise we have to add one to
// insert after the corresponding child
int childIndex = (startPos.Offset == 0) ? startPos.Index : startPos.Index + 1;
using (generator.StartAt(startPos, GeneratorDirection.Forward, true)) {
for (int itemIndex = firstVisibleItemIndex; itemIndex <= lastVisibleItemIndex; ++itemIndex, ++childIndex) {
bool newlyRealized;
// Get or create the child
UIElement child = generator.GenerateNext(out newlyRealized) as UIElement;
if (newlyRealized) {
// Figure out if we need to insert the child at the end or somewhere in the middle
if (childIndex >= children.Count) {
base.AddInternalChild(child);
} else {
base.InsertInternalChild(childIndex, child);
}
generator.PrepareItemContainer(child);
} else {
// The child has already been created, let's be sure it's in the right spot
Debug.Assert(child == children[childIndex], "Wrong child was generated");
}
// Measurements will depend on layout algorithm
child.Measure(this.ChildSize);
}
}
// Note: this could be deferred to idle time for efficiency
CleanUpItems(firstVisibleItemIndex, lastVisibleItemIndex);
return desiredSize;
}
private void CleanUpItems(int firstVisibleItemIndex, int lastVisibleItemIndex) {
UIElementCollection children = this.InternalChildren;
IItemContainerGenerator generator = this.ItemContainerGenerator;
for (int i = children.Count - 1; i >= 0; i--) {
// Map a child index to an item index by going through a generator position
GeneratorPosition childGeneratorPos = new GeneratorPosition(i, 0);
int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPos);
if (itemIndex < firstVisibleItemIndex || itemIndex > lastVisibleItemIndex) {
generator.Remove(childGeneratorPos, 1);
RemoveInternalChildRange(i, 1);
}
}
}
private Size ChildSize = new Size();
That’s it!关于virtualization特定的代码并没有那么多,MeasureCore中的循环会创建那些miss的children,然后不论新旧都需要重新Measure他们。在cleanup代码中移除那些不再显示的UI element。上面的代码有很多优化的点,比如说你可以在空闲时延迟来操作cleanup事项,你也可以保留额外的item以备之后重用,同样,批量移除children会比一个个移除更有效率。
我们来看一个假设的case,当前的view可以一次显示10个item,我想这个不是太难理解,可能通过完整的代码一步步理解起来会容易一些,不顾哦我会在后续的post中展开。
当第一次显示panel时,firstVisibleItemIndex=0,lastVisibleItemIndex=9,childIndex=0。在loop中会create并measure10次,依次加入到队列中。cleanup会遍历列表,但不会remove anyone。
Now,当你稍稍向下滚动时,2-11变为可见,在measure中startPosition变为item 2即index=2,offset=0。这会对应到child index 2。那么从2-9都是之前创建的,所以NewlyRealized=false,而后2项则是需要新创建的,在cleanup中会移除0,1两个item。
如果item集合发生变化时,比如说在index 8之后加了一个item,这是会引发一个重新measure的过程。IItemContainerGenerator会知道新的item被创建,然后我们就需要重新计算位置,并将新的item创建出来并插入到合适的位置上。
那么,如果从item集合中移除一个item呢?同样的,我们还是会重新measure,IItemContainerGenerator会知道remove item信息,但问题是panel中UI Element对应的item被generator移除后无法匹配了,这会是一个问题。解决办法是override OnItemsChanged,在这个方法中立即更新,这个时候genernator还没动手,我会在下一篇中包含上述讨论的所有代码。
Ok,我在这篇中会得到一个完整的实现。我会展示如何实现VirtualizingTilePanel。下面是screenshot,

这个demo可以让你insert或是delete item,你可以通过它来确认他是怎么工作的,是否如你所期望的那样。
当你在实现一个像上面的panel时,你会在scrolling behavior上有多个选择,你可以按pixel滚动,也可以按item滚动。VirtualizingStackPanel(ListBox默认采用的ItemsPanel)是基于item进行滚动的。总体来说基于item可以降低滚动计算的复杂度,而Pixel-based的滚动只有在固定尺寸的情况下才会容易些,否则也很难处理。在这个panel中,我会采用基于pixel滚动方式。如果有兴趣的话,我也可以写一个基于item的滚动。panel会根据children的宽度决定一行显示几个,需要几行。
除非有特别的问题,我不想写太多关于harness(panel以外的代码)的实现。基本上来说只是创建了一个ObservableCollection
我将实现VirtualizingTilePanel分为3个部分:
第一步是实现IScrollInfo。我基本上就是根据BenCon的post操作的。他到现在还没完成最后一篇[译注:2006年冬天的那篇,现在有了,呵呵],所以在处理resize上又是有些问题,并且没有实现MakeVisible。另外,我也不得不在scrolling后强制调用InvalideMeasure来做realize。这里我也只支持垂直方向。
第二步是关于布局问题。我试图封装布局这部分逻辑,能够让你可以替换你的布局进去,创建一个基类完成通用性的逻辑,然后布局的内容在子类化完成,这个内容我会放在下一步操作。
最后一步就是实现ArrangeOverride,实现方式如下:
protected override Size ArrangeOverride(Size finalSize) {
IItemContainerGenerator generator = this.ItemContainerGenerator;
UpdateScrollInfo(finalSize);
for (int i = 0; i < this.Children.Count; i++) {
UIElement child = this.Children[i];
// Map the child offset to an item offset
int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0));
ArrangeChild(itemIndex, child, finalSize);
}
return finalSize;
}
唯一值得说的是如何将child index转化为item index,我们需要根据item index来决定将child放在什么位置上。
下面演示了如何处理item被移除的情况,
protected override void OnItemsChanged(object sender, ItemsChangedEventArgs args) {
switch (args.Action) {
case NotifyCollectionChangedAction.Remove:
case NotifyCollectionChangedAction.Replace:
case NotifyCollectionChangedAction.Move:
RemoveInternalChildRange(args.Position.Index, args.ItemUICount);
break;
}
}
当移除data item时,我们会移除对应的realized children,我不得不说我不敢保证所有的情况下这个代码都能正确工作。ObservableCollection<>只支持single removing,我们在一些复杂的情况下进行测试。
代码可以在http://www.boingo.org/samples/VirtualizingTilePanelSample.zip下载,It works with December/January CTP。
我希望能得到关于这个内容的反馈,让我知道你是怎么想的,又或者你有什么问题。
提问地址:http://blogs.msdn.com/b/dancre/archive/2006/02/16/implementing-a-virtualizingpanel-part-4-the-goods.aspx
不过作者估计已经闪了,毕竟06的文章了。
在对创建Item时机上把握还不够,横冲直撞写了些代码也感觉不甚理想,还是静下来仔细看看几篇不错的blog,顺便无责任精简翻译一下,文章包括2个系列
最近我花了些时间学习如何在一个control上实现IScrollInfo接口。这是一段很有趣的学习过程,相关的文档都比较少(2006),我发现很多人都想知道这块内容,我决定分几篇来写这块内容。
Why would you implement this interface?
如果你碰到过当一个你自定义的layout panel在一个ScrollView中,并且你想对布局和滚动有更多更直接的控制时,那么这个时候你最好将ScrollViewer的CanContentScroll设为True,与此同时,你需要为在ScrollViewer中的control自己来实现IScrollInfo。你会从中获得2个benefits:
Why don’t people always implement the interface?
IScrollInfo是一个large interface(差不多有15个methods+9个Properties,Dec CTP build of Avalon),而且没有好的实现接口指南。但是你很幸运,这个系列的post将会改变现状。
我们现在开始,新建一个WPF project,然后new一个名为AnnoyingPanel的class,我们会实现一个垂直排列children的annoying panel,我们会一直修改children的尺寸始终让这个尺寸是viewport高度的2倍。用这样一个方法来证明我们可以获知scroll的viewport尺寸,以及证明如何实现缄口并跟踪滚动信息。[update]AnnoyingPanel继承自Panel,并让他处于public。
第一步,我们给AnnoyingPanel加上IScrollInfo接口。
public class AnnoyingPanel : Panel, IScrollInfo {
...
}
在Visual Studio中在IScrollInfo上Alt+Shift+F10为对象自动添加stub,先让编译通过。
然后将这个新的panel放入到Window的XAML中。
<Window x:Class="Test04.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Test04"
Title="Test04" Height="550" Width="900">
<Grid>
<ScrollViewer CanContentScroll="True">
<local:AnnoyingPanel>
<Button>button</Button>
</local:AnnoyingPanel>
</ScrollViewer>
</Grid>
</Window>
As you can see,我们将ScrollViewer的CanContentScroll设为True,并且放入了AnnoyingPanel,最后我们把一个button放到Panel中。
如果你这时候编译程序并运行的话,会在AnnoyingPanel中的ScrollOwner.Setter时得到NotImplementedException,因为我们前面通过Alt+Shift+F10生成的stub中布满NotImplementedException,同时我们获知ScrollViewer会侦测到IScrollInfo并将自身设置为Panel的ScrollOwner,修改getter/setter
public ScrollViewer ScrollOwner { get; set; }
[译注:作者用了变量_owner来完成get/set,2006年那会是没办法,我这里就直接升级到.NET4.0]
然后再次运行(Debug)你会在CanHorizontallyScroll的Setter再次发生NotImplementException,这是因为默认情况下,开启垂直滚动条同时禁用水平滚动条。ScrollViewer会主动告知content禁用水平滚动,所以你设个断点可以看到传入的value是false。这个去掉NotImplementException即可。
public bool CanHorizontallyScroll { get; set; }
public bool CanVerticallyScroll { get; set; }
现在你运行程序将不会再有Exception,虽然Panel中还有很多未去掉的NotImplementException,这是你会得到一个blank window。Wow – is that all?Not quite.如果你滚动任何内容都会产生NotImplementException,还有button也没有如期显示。但总算我们在第一步把样子先搭出来了。
Next post: 我们开始给Panel写Measure和Arrange,并且看看下一步我们需要IScrollInfo中哪些方法。
补充一下 by nonocast
根据CallStack,我们可以发现ScrollViewer在OnApplyTemplate中调用了方法HookupScrollingComponent,在这个方法中针对IScrollInfo分别设置ScrollOwner,CanHorizontallyScroll(false)和CanVerticallyScroll(true)。
虽然老外有点罗嗦,不过一步一步很清晰把我们带入到接口的交互过程中。
接上,我们现在需要让button显示出来,之后实现滚动。
首先我们实现MeasureOverride。我们通过获取viewport尺寸然后measure相应的elements。
通过measure我们可以计算出element总共占用多少空间,然后给出AnnoyingPanel的所需空间。所以整个AnnoyingPanel的尺寸我们称之为Extent区域。从IScrollInfo接口来看,我需要将Extent区域范围设置到IScrollInfo.ExtentWidth/ExtentHeight上,通过这个设置告知ScrollViewer整个Extent区域大小,然后还需要告知Viewport和Offset。
在AnnoyingPanel中,由于child的size依赖于viewport,之前说到的button size是viewport的2倍,所以每当MeasureOverride被调用时会导致extent改变。child width等同于viewport width,child height则是2倍于viewport height。如果超过一个child,则和stackpanel一致,垂直向下堆叠(stack)。我们现在已经知道什么是viewport和extent,我们需要增加一些fildes来跟踪他们,并在MeasureOverride中update。代码如下:
protected override Size MeasureOverride(Size availableSize) {
Size childSize = new Size(
availableSize.Width,
(availableSize.Height * 2) / this.InternalChildren.Count);
Size extentTmp = new Size(
availableSize.Width,
childSize.Height * this.InternalChildren.Count);
if (extentTmp != extent) {
extent = extentTmp;
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
}
if (viewport != availableSize) {
viewport = availableSize;
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
}
foreach (UIElement child in InternalChildren) {
child.Measure(childSize);
}
return availableSize;
}
private Size extent = new Size();
private Size viewport = new Size();
译注:代码稍作修改,意思是一样的,ScrollViewer在MeasureOverride时传入的availableSize就是viewport尺寸,而非infinity,这个很关键。
运行后,会在HorizontalOffset的Getter时得到NotImplementException,看起来我们又有了一些进展,因为我们让ScrollOwner对我们产生了好奇,我们在设置了extent和viewport后,ScrollOwner还希望知道滚动到什么位置确定显示区域(这个称为offset)。我们同样需要用一个field来保存offset,现在我们可以对应实现IScrollInfo中的property了,因为ScrollOwner要开始问我们取数据了:
public double ViewportHeight {
get { return viewport.Height; }
}
public double ViewportWidth {
get { return viewport.Width; }
}
public double ExtentWidth {
get { return extent.Width; }
}
public double ExtentHeight {
get { return extent.Height; }
}
public double HorizontalOffset {
get { return offset.X; }
}
public double VerticalOffset {
get { return offset.Y; }
}
private Size extent = new Size();
private Size viewport = new Size();
private Vector offset = new Vector();
编译运行,没有异常,滚动条看上去不错,1/2的效果出来了,不过button还是没有显示出来!那是因为没有Arrrange嘛,同时拖动滚动条时在SetVerticalOffset产生exception。现在是时候不上ArrangeOverride了。在这个panel中,ArrangeOverride中很大一部分逻辑和MeasureOverride中是一致的(通常情况下custom panel倾向在measure中cache children的location和size,而不是在arrange中repeat一遍,but我们这里只是scrolling tutorial,并且也不是layout tutorial,so我们就省略了这部分)。Arrange不同的地方只是在其中调用了children的Arrange,其中包括了position,Measure是不包括position。
protected override Size ArrangeOverride(Size finalSize) {
Size childSize = new Size(
finalSize.Width,
(finalSize.Height * 2) / this.InternalChildren.Count);
Size extentTmp = new Size(
finalSize.Width,
childSize.Height * this.InternalChildren.Count);
if (extentTmp != extent) {
extent = extentTmp;
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
}
if (viewport != finalSize) {
viewport = finalSize;
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
}
for (int i = 0; i < this.InternalChildren.Count; i++) {
this.InternalChildren[i].Arrange(new Rect(0, childSize.Height * i, childSize.Width, childSize.Height));
}
return finalSize;
}
编译运行,这个时候我们终于可以看到button了,我们可以看见button的上半部分,这个时候边上的滚动条中thumb正好是整个tracker的1/2。
如果你操作scrollbar,你会得到exception,习惯了。这是因为我们没有实现任何scroll command methods,类似LineUp或是SetVerticalOffset。Part III我们会来解决它。

译注: 这里我把button的FontSize调大到了80,看起来清楚点,resize窗口也很正常,触发MeasureOverride重新根据viewport计算尺寸,始终保持2倍viewport height。很好。
当我们拖动垂直滚动条上的thumb时,SetVerticalOffset会被调用。ScrollViewer通过这个方法告知panel相应的offset,你可以做出相应的动作,给出显示区域。但问题是即使ScrollViewer通知你现在scroll发生变化了,这时panel中的内容也不会随着滚动,你可以去掉SetVerticalOffset中的exception然后运行试试,关键问题在于我们主动去实现了IScaollInfo接口并且设定ScrollViewer的CanContentScroll为True,这个意思就是说我自己来搞定Scroll,你不要管了。那实现Scroll的其中一个方法就是使用RenderTransform。
public AnnoyingPanel() {
this.RenderTransform = translate;
}
private TranslateTransform translate = new TranslateTransform();
有了translate我们就可以实现SetVerticalOffset了,如下:
public void SetVerticalOffset(double arg) {
if (arg < 0 || viewport.Height >= extent.Height) {
arg = 0;
} else {
if (arg + viewport.Height >= extent.Height) {
arg = extent.Height - viewport.Height;
}
}
offset.Y = arg;
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
translate.Y = -arg;
}
解释一下开头的check内容,考虑到我们后续会在实现其他scrolling methods时使用这个方法,所以check的严谨一些。那我们到底check了什么呢?首先我们判断arg是否小于0或者viewport大于等于extent(不需要滚动即可显示所有内容),这两种情况下,我们将arg清0。
译注: arg的范围在[0,extent.height-viewport.height]之间,所以也就不会出现负数。
另一个判断这是判断是否大于最大范围,如果超出范围则设置到最大值上,即extent.height-viewport.height。
现在编译运行app你会发现你可以拖动滚动条来滑动并显示其中内容,Isn’t that cool? 如你所知,我完全了绝大部分工作,接近尾声,所有剩下的scroll command methods都依赖于SetVerticalOffset。代码如下:
public void LineUp() {
SetVerticalOffset(this.VerticalOffset - 1);
}
public void LineDown() {
SetVerticalOffset(this.VerticalOffset + 1);
}
public void PageUp() {
double childHeight = (viewport.Height * 2) / this.InternalChildren.Count;
SetVerticalOffset(this.VerticalOffset - childHeight);
}
public void PageDown() {
double childHeight = (viewport.Height * 2) / this.InternalChildren.Count;
SetVerticalOffset(this.VerticalOffset + childHeight);
}
public void MouseWheelUp() {
SetVerticalOffset(this.VerticalOffset - 10);
}
public void MouseWheelDown() {
SetVerticalOffset(this.VerticalOffset + 10);
}
现在我们完成了所有和垂直滚动条交互的行为,包括鼠标滚动。现在是时候结束了吗?还不行,我们还没有实现MakeVisible,还有一些resize带来的问题。我们会在后一篇中解释这些细节问题。
继续,我们现在只剩下IScrollInfo.MakeVisible需要实现。MakeVisible的意图是让Panel显示某个child。那么什么时候会被调用呢?如果你之前有跟着写AnnoyPanel,那么click panel中的button,程序又会得到NotImplementException,这个exception正是来自MakeVisible中。按Tab键切换focus也同样会触发MakeVisible。这样设计是有道理–当你click或是tab到这个button时,ScrollViewer会希望这个button处于用户可视范围中。
So how do we implement the method?
这个方法会传入2个参数,a Visual and a Rect。Visual就是需要让其显示的内容–他应该是Panel.Children的一员。Rect则是附加说明Visual的哪部分需要被显示。返回值同样也是一个Rect,表明现在显示了Visual的区域。
所以看起来是实现这个方法应该很简单。我们首先在child list中确保Visual是其中一员,然后根据其位置计算panel的偏移,当然返回的Rect应该小于viewport,代码如下:
public Rect MakeVisible(Visual visual, Rect rectangle) {
for (int i = 0; i < this.InternalChildren.Count; i++) {
if ((Visual)this.InternalChildren[i] == visual) {
// we found the visual! Let's scroll it into view. First we need to know how big
// each child is.
Size finalSize = this.RenderSize;
Size childSize = new Size(
finalSize.Width,
(finalSize.Height * 2) / this.InternalChildren.Count);
// now we can calculate the vertical offset that we need and set it
SetVerticalOffset(childSize.Height * i);
if (ScrollOwner != null) { ScrollOwner.InvalidateScrollInfo(); }
// child size is always smaller than viewport, because that is what makes the Panel
// an AnnoyingPanel.
return rectangle;
}
}
throw new ArgumentException("Given visual is not in this Panel");
}
运行程序后,先将滚动条往下移动,然后点击button,会发现button回到顶部,再次按无效,因为当前visbile是同一个对象就忽略了,多加几个button试试:

现在我们就有一个minimal working版本的MakeVisible。
在real world application中,关于接口实现我们还需要考虑一下几点:
译注: 不是很好翻,直接看原文更简单。
所有上述四件事情是incremental(增量)的,通常需要根据你的具体情况做出选择,这是为什么我不能太具体的原因。IScrollInfo拥有很大的灵活性,设计了很多扩展点和扩展方式,你有多种方式来使用它。我试图做的就是让你明白这其中的可能性。
下载代码,戳。
由于需要实现平滑的VirtualizingPanel,所以第一步就必须明确ListBox背后的‘故事’。
ListBox is ItemsControl,而ItemsControl和Panel在FrameworkElement后分道扬镳,最为关键是的是ItemsControl从技术上来讲是逻辑概念,最终还是需要通过Template借助Panel方能落地,所以我们把注意力放在ItemsControl和Panel2个概念上,看看两个概念是如何交互的。
从概念上来说,Panel是UIElement的container,正如Dr.WPF所说,Panel职责有三,
很关键的一点就是Panel是针对UIElement,而不是逻辑对象,从另一个方面来说,ItemsControl的ItemsSource就是针对逻辑对象,通过ItemTemplate建立起一个从逻辑对象到UIElement的映射通道。
1. ItemsControl.CreateItemCollectionAndGenerator
private void CreateItemCollectionAndGenerator() {
_items = new ItemCollection(this);
_itemContainerGenerator = new ItemContainerGenerator(this);
_itemContainerGenerator.ChangeAlternationCount();
...
}
*只取了部分,完整源代码可以在文末下载。
在Item和ItemContainerGenerator的Getter中都会调用这个方法,其实就是lazy创建Generator。Generator就是Items和UIElement的桥梁。
2. ItemContainerGenerator
An ItemContainerGenerator is responsible for generating the UI on behalf of its host (e.g. ItemsControl). It maintains the association between the items in the control’s data view and the corresponding UIElements. The control’s item-host can ask the ItemContainerGenerator for a Generator, which does the actual generation of UI.
ItemContainerGenerator < IItemContainerGenerator,但这里很奇妙的采用显示接口实现方式(Explicit Interface Implementation),所以需要as到IItemContainerGenerator才能使用接口方法,难道为了隐藏细节吗?
IItemContainerGenerator Interface
ItemContainerGenerator Class
3. 我们从另外一个角度出发,来关心一下UIElement创建的时机,通过在UIElement构造中设置Break Point,可以发现整个UIElement创建过程处于Panel的UpdateLayout中的Measure阶段

在Measure中调用了InternalChildren进而触发EnsureGenerator转而到GenerateChildren,这个方法很关键后面会提及。
Panel.GenerateChildren
internal virtual void GenerateChildren() {
IItemContainerGenerator generator = (IItemContainerGenerator)_itemContainerGenerator;
if (generator != null) {
using (generator.StartAt(new GeneratorPosition(-1, 0), GeneratorDirection.Forward)) {
UIElement child;
while ((child = generator.GenerateNext() as UIElement) != null) {
_uiElementCollection.AddInternal(child);
generator.PrepareItemContainer(child);
}
}
}
}
4. 那么Panel是如何和ItemsControl建立联系,并在GenerateChildren中能获取到ItemsControl的ItemsSource的数据呢?关键在于ItemsControl提供的一个静态方法GetItemsOwner
ItemsControl.GetItemsOwner
public static ItemsControl GetItemsOwner(DependencyObject element) {
ItemsControl container = null;
Panel panel = element as Panel;
if (panel != null && panel.IsItemsHost) {
// see if element was generated for an ItemsPresenter
ItemsPresenter ip = ItemsPresenter.FromPanel(panel);
if (ip != null) {
// if so use the element whose style begat the ItemsPresenter
container = ip.Owner;
} else {
// otherwise use element's templated parent
container = panel.TemplatedParent as ItemsControl;
}
}
return container;
}
通过这个方法我们就可以在Panel中检查自己是否是ItemsControl中的ItemsHost,如果是,就把Generator取到手进行GenerateChildren。
Panel有2个状态,分别是bound和unbound,我们在写ListBox的Template中可以直接设定Panel的IsItemsHost=true,又或者将Panel放入ItemsPanelTemplate中都能另这个Panel处于bound状态,在bound状态下通过GetItemsOwner就能获取bounded的source,即ListBox,从ItemsControl得到Generator创建Children,OK,差不多就是这样一个情形,anyway,我自己已经说服我自己了。
最后补一句,为什么说GenerateChildren很重要,因为这就是VirtualizingPanel的一个重要变化点,在VirtualizingPanel中不再是Measure中创建所有Children,而是依赖视口(Viewport,可视范围)进行按需创建,大大降低了创建UIElement成本,区分一个概念,但Items到位UIElement按需创建这种情况我们称之为UI Virtualization,而如果Items是按需创建则称为Data Virualization,在网络、数据库取数据时常有用到,但我现在的麻烦在于创建Image时占用大量memory,所以对我来说我目前关注前者UIV。
VirtualizingPanel.GenerateChildren
internal override void GenerateChildren() {
// Do nothing. Subclasses will use the exposed generator to generate children.
}
对熟悉ITemsControl很有帮助
(but not necessarily in that order)
图个方便,给个中文的版本,戳。
折腾了一个晚上,习惯性CanContentScroll=false,于是Virtualizing就罢工了。
source: http://msdn.microsoft.com/en-us/library/cc716879.aspx
Unfortunately, you can disable UI virtualization for these controls without realizing it. The following is a list of conditions that disable UI virtualization.
Currently, no WPF controls offer built-in support for data virtualization.
在bea的blog也得到了类似的求证,
.NET 3.5 SP1 fixed many previous limitations on UI virtualization, but a couple still remain:
参考文档
针对ListBox可以对以下内容编写Style
Template和ItemsPanel针对ListBox整体,而ItemContainerStyle和ItemTemplate则针对单个Item元素。
首先看ListBox的控制,Template > ItemsPanel,ItemsPanel控制Item布局,Stack或是Wrap,所以当我们需要更换布局时仅需重写对应的Panel即可,这样Panel可以在ListBox/ListView/ComboBox中复用,这就是将Template和ItemsPanel分开的原因(我猜)。
同理,ItemContainerStyle则是这个Item的Style,而具体到每个Item什么形式显示就需要借助ItemTemplate,而ItemTemplate就对应到DataTemplate,为了复用将ItemContainerStyle和ItemTemplate分开,增加了复杂度,但只要掌握分开的道理,就可以在其他ItemsControls上获得一致的经验。
<ListBox x:Name="ListBoxPages" Style="{StaticResource ListBoxPagesStyle}"
ItemContainerStyle="{StaticResource PageListBoxItemStyle}"
ItemTemplate="{StaticResource PageStyle}"/>
我们在Style中设定了ListBox的Template和ItemsPanel,如下
<Style x:Key="ListBoxPagesStyle" TargetType="ListBox">
<Setter Property="ScrollViewer.CanContentScroll" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<ScrollViewer Margin="0" Focusable="false">
<ItemsPresenter />
</ScrollViewer>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel HorizontalAlignment="Stretch" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
针对ListBoxItem的Container我们设定如下
<Style x:Key="PageListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border SnapsToDevicePixels="true" HorizontalAlignment="Stretch" Margin="0,10">
<Border.Effect>
<DropShadowEffect Opacity=".5" />
</Border.Effect>
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
由于Item是一个图片的FileInfo,所以对应的DataTemplate只要绑定到Image的Source上即可
<DataTemplate x:Key="PageStyle">
<Image Source="{Binding FullName}" Stretch="Uniform" MaxWidth="200" MaxHeight="200" />
</DataTemplate>
下载代码: 戳。
做了一版左右滑动的图片浏览器,支持Thumbnail目录显示,其中难点在于Panel的布局,
public class SlidePanel : Canvas {
public SlidePanel() {
Brush br = new SolidColorBrush(Color.FromRgb(0x29, 0x29, 0x29));
br.Freeze();
this.Background = br;
}
protected override Size MeasureOverride(Size constraint) {
Size result = constraint;
if (InternalChildren.Count > 0) {
int count = ((InternalChildren[0] as PhotoVisual).DataContext as Photo).BelongsTo.Photos.Count;
result.Width *= count;
}
return result;
}
protected override Size ArrangeOverride(Size arrangeSize) {
if (InternalChildren.Count > 0) {
int count = ((InternalChildren[0] as PhotoVisual).DataContext as Photo).BelongsTo.Photos.Count;
double width = arrangeSize.Width / count;
foreach (PhotoVisual each in InternalChildren) {
int index = (each.DataContext as Photo).PhotoNumber;
each.Arrange(new Rect(new Point(width * index, 0), new Size(width, arrangeSize.Height)));
}
}
return arrangeSize;
}
}
根据图片数量,给出一个容下所有的Canvas,然后每张图片根据其Index计算他在Canvas中的Rect,程序会根据当前显示的视口(Viewport)将不需要的PhotoVisual移除以提高内存效率,类似VisualStackPanel手法。
代码下载: 戳。



