将布局逻辑委托给另一个对象的容器(例如面板),它依赖于附加的布局对象来为其子元素提供布局行为。 附加布局模型为应用程序提供了在运行时更改项目布局的灵活性,或者更轻松地在 UI 的不同部分之间共享布局的各个方面(例如,表中似乎在列内对齐的行中的项)。
在本主题中,我们将介绍创建附加布局(虚拟化和非虚拟化)、需要理解的概念和类,以及在它们之间做出决定时需要考虑的权衡。
| 获取 WinUI |
|---|
| 此控件包含在 WinUI 中,它是一个 NuGet 包,其中包含 Windows 应用的新控件和 UI 功能。 有关详细信息(包括安装说明),请参阅 WinUI 概述。 |
重要 API:
关键概念
执行布局需要为每个元素回答两个问题:
此 元素的大小是什么 ?
此元素 的位置 是什么?
XAML 的布局系统在讨论 自定义面板时简要介绍了这些问题。
容器和上下文
从概念上讲,XAML 的 面板 在框架中填充了两个重要角色:
- 它可以包含子元素,并在元素树中引入分支。
- 它将特定布局策略应用于这些子级。
因此,XAML 中的面板通常与布局同义,但从技术上讲讲,不仅仅是布局。
ItemsRepeater 的行为也类似于 Panel,但与 Panel 不同,它不会公开允许以编程方式添加或删除 UIElement 子级的 Children 属性。 相反,其子级的生存期由框架自动管理,以对应于数据项的集合。 虽然它不是派生自 Panel,但它的行为和被框架视为面板。
注释
LayoutPanel 是派生自 Panel 的容器,它将其逻辑委托给附加的 Layout 对象。 LayoutPanel 处于 预览状态 ,目前仅在 WinUI 包的 预发行版 删除中可用。
容器
从概念上讲, Panel 是元素的容器,还可以呈现 背景的像素。 面板提供了一种在易于使用的包中封装常见布局逻辑的方法。
附加布局的概念使容器和布局的两个角色之间的区别更加清晰。 如果容器将其布局逻辑委托给另一个对象,我们将调用附加布局的对象,如以下代码片段所示。 从 FrameworkElement 继承的容器(例如 LayoutPanel)会自动公开向 XAML 布局过程(例如高度和宽度)提供输入的常用属性。
<LayoutPanel>
<LayoutPanel.Layout>
<UniformGridLayout/>
</LayoutPanel.Layout>
<Button Content="1"/>
<Button Content="2"/>
<Button Content="3"/>
</LayoutPanel>
在布局过程中,容器依赖于附加 的 UniformGridLayout 来测量和排列其子级。
Per-Container 状态
使用附加布局时,布局对象的单个实例可能与 许多 容器相关联,如以下代码片段所示:因此,它不能依赖于或直接引用主机容器。 例如:
<!-- ... --->
<Page.Resources>
<ExampleLayout x:Name="exampleLayout"/>
<Page.Resources>
<LayoutPanel x:Name="example1" Layout="{StaticResource exampleLayout}"/>
<LayoutPanel x:Name="example2" Layout="{StaticResource exampleLayout}"/>
<!-- ... --->
在这种情况下, ExampleLayout 必须仔细考虑它在布局计算中使用的状态,以及存储该状态的位置,以避免影响一个面板中的元素的布局。 它类似于一个自定义面板,其 MeasureOverride 和 ArrangeOverride 逻辑取决于其 静态 属性的值。
LayoutContext
LayoutContext 的目的是应对这些挑战。 它使附加布局能够与主机容器进行交互,例如检索子元素,而无需在两者之间引入直接依赖项。 上下文还允许布局存储它所需的任何状态,这些状态可能与容器的子元素相关。
简单、非虚拟化的布局通常不需要维护任何状态,使其成为非问题。 但是,更复杂的布局(如网格)可以选择在度量值和排列调用之间保持状态,以避免重新计算值。
虚拟化布局 通常需要 在度量值和排列之间以及迭代布局传递之间保持一些状态。
初始化和取消初始化 Per-Container 状态
当布局附加到容器时,将调用其 InitializeForContextCore 方法,并提供初始化对象以存储状态的机会。
同样,从容器中删除布局时,将调用 UninitializeForContextCore 方法。 这样,布局就有机会清理与该容器关联的任何状态。
布局的状态对象可以使用上下文上的 LayoutState 属性在容器中存储和检索。
UI 虚拟化
UI 虚拟化意味着在需要 UI 对象 之前延迟创建 UI 对象。 这是性能优化。 对于确定 何时需要 的非滚动方案,可能基于特定于应用的任意数量的事项。 在这些情况下,应用应考虑使用 x:Load。 它不需要在布局中进行任何特殊处理。
在基于滚动的方案(如列表)中,确定 何时需要 通常基于“用户是否可见”,这在很大程度上取决于它在布局过程中的位置,并且需要特殊注意事项。 此方案是本文档的重点。
注释
尽管本文档未介绍,但在滚动方案中启用 UI 虚拟化的相同功能可以在非滚动方案中应用。 例如,一个数据驱动的 ToolBar 控件,它通过回收/移动可见区域和溢出菜单之间的元素来管理它呈现的命令的生存期,并响应可用空间的变化。
入门
首先,确定是否需要创建的布局应支持 UI 虚拟化。
要记住的一些事项...
- 非虚拟化布局更易于创作。 如果项数始终较小,则建议创作非虚拟化布局。
- 该平台提供了一组附加布局,这些布局适用于 ItemsRepeater 和 LayoutPanel ,以满足常见需求。 在决定需要定义自定义布局之前熟悉这些布局。
- 与非虚拟化布局相比,虚拟化布局始终具有一些额外的 CPU 和内存成本/复杂性/开销。 作为一般经验法则,如果布局需要管理的子级可能适合视区大小为 3 倍的区域,那么虚拟化布局可能没有太大的收益。 本文档稍后将更详细地讨论 3 倍大小,但由于在 Windows 上滚动及其对虚拟化的影响的异步性质。
小窍门
作为参考点, ListView (和 ItemsRepeater)的默认设置是,在项数足以填满当前视区大小的 3 倍之前,回收才会开始。
选择基类型
基本 布局 类型有两个派生类型,用作创作附加布局的起点:
非虚拟化布局
创建非虚拟化布局的方法应该对已创建 自定义面板的任何人感到熟悉。 相同的概念适用。 主要区别是 ,NonVirtualizingLayoutContext 用于访问 Children 集合,布局可以选择存储状态。
- 派生自基类型 NonVirtualizingLayout (而不是 Panel)。
- (可选) 定义更改后会使布局失效的依赖项属性。
- (新增/可选) 将布局所需的任何状态对象初始化为 InitializeForContextCore 的一部分。 通过使用上下文提供的 LayoutState ,将其与主机容器一起存储。
- 重写 MeasureOverride 并对所有子级调用 Measure 方法。
- 重写 ArrangeOverride 并对所有子级调用 Arrange 方法。
- (新增/可选) 清理任何保存的状态作为 UninitializeForContextCore 的一部分。
示例:简单的堆栈布局(Varying-Sized 项)
下面是不同大小的项的非常基本的非虚拟化堆栈布局。 它缺少任何属性来调整布局的行为。 下面的实现说明了布局如何依赖于容器提供的上下文对象来:
- 获取子级计数,以及
- 按索引访问每个子元素。
public class MyStackLayout : NonVirtualizingLayout
{
protected override Size MeasureOverride(NonVirtualizingLayoutContext context, Size availableSize)
{
double extentHeight = 0.0;
foreach (var element in context.Children)
{
element.Measure(availableSize);
extentHeight += element.DesiredSize.Height;
}
return new Size(availableSize.Width, extentHeight);
}
protected override Size ArrangeOverride(NonVirtualizingLayoutContext context, Size finalSize)
{
double offset = 0.0;
foreach (var element in context.Children)
{
element.Arrange(
new Rect(0, offset, finalSize.Width, element.DesiredSize.Height));
offset += element.DesiredSize.Height;
}
return finalSize;
}
}
<LayoutPanel MaxWidth="196">
<LayoutPanel.Layout>
<local:MyStackLayout/>
</LayoutPanel.Layout>
<Button HorizontalAlignment="Stretch">1</Button>
<Button HorizontalAlignment="Right">2</Button>
<Button HorizontalAlignment="Center">3</Button>
<Button>4</Button>
</LayoutPanel>
虚拟化布局
与非虚拟化布局类似,虚拟化布局的高级步骤相同。 复杂性主要在于确定哪些元素将属于视区,应实现。
- 派生自基类型 VirtualizingLayout。
- (可选)定义更改后会使布局失效的依赖项属性。
- 初始化布局作为 InitializeForContextCore 的一部分所需的任何状态对象。 通过使用上下文提供的 LayoutState ,将其与主机容器一起存储。
- 重写 MeasureOverride 并调用应实现的每个子项的 Measure 方法。
- GetOrCreateElementAt 方法用于检索框架已准备的 UIElement(例如,应用的数据绑定)。
- 重写 ArrangeOverride 并为每个已实现的子级调用 Arrange 方法。
- (可选)清理任何保存的状态作为 UninitializeForContextCore 的一部分。
小窍门
MeasureOverride 返回的值用作虚拟化内容的大小。
创作虚拟化布局时,需要考虑两种常规方法。 是否选择一个或另一个在很大程度上取决于“如何确定元素的大小”。 如果它足以知道数据集中某个项的索引或数据本身决定了其最终大小,那么我们会将其视为 数据依赖型。 这些内容更易于创建。 但是,如果确定项的大小的唯一方法是创建和测量 UI,那么我们会说它依赖于 内容。 这些更为复杂。
布局过程
无论是创建数据还是依赖于内容的布局,请务必了解布局过程以及 Windows 异步滚动的影响。
框架从启动到在屏幕上显示 UI 的步骤的简化视图是:
它分析标记。
生成元素树。
执行布局传递。
执行呈现传递。
使用 UI 虚拟化,创建通常在步骤 2 中完成的元素会在确定创建足够的内容以填充视区后延迟或提前结束。 虚拟化容器(例如 ItemsRepeater)会延迟其附加布局来驱动此过程。 它为附加的布局提供了 VirtualizingLayoutContext ,它显示虚拟化布局所需的附加信息。
RealizationRect (即视区)
在 Windows 上滚动作对 UI 线程进行异步作。 它不受框架的布局控制。 相反,交互和移动发生在系统的合成器中。 此方法的优点是,平移内容始终可以在 60fps 上完成。 然而,挑战在于,布局看到的“视区”可能与屏幕上实际可见的内容略有过时。 如果用户快速滚动,他们可能会超过 UI 线程的速度,以生成新内容并“平移到黑色”。 因此,虚拟化布局通常需要生成足以填充大于视口的区域的已准备元素的附加缓冲区。 在滚动期间加载较重时,用户仍会显示内容。
由于元素创建成本高昂,因此虚拟化容器(例如 ItemsRepeater)最初将为附加布局提供与视区匹配的 RealizationRect 。 在空闲时间,容器可以通过使用越来越大的实现 rect 对布局进行重复调用来增大已准备内容的缓冲区。 此行为是一种性能优化,它尝试在快速启动时间和良好的平移体验之间取得平衡。 ItemsRepeater 将生成的最大缓冲区大小由 其 VerticalCacheLength 和 HorizontalCacheLength 属性控制。
重新使用元素 (回收)
布局应调整和定位元素,以在每次运行时填充 RealizationRect 。 默认情况下, VirtualizingLayout 将在每个布局传递结束时回收任何未使用的元素。
作为 MeasureOverride 和 ArrangeOverride 的一部分传递给布局的 VirtualizingLayoutContext 提供了虚拟化布局所需的附加信息。 它提供的一些最常用的内容是能够:
- 查询数据中的项数(ItemCount)。
- 使用 GetItemAt 方法检索特定项。
- 检索 一个 RealizeRect ,表示布局应填充到已实现的元素的视口和缓冲区。
- 使用 GetOrCreateElement 方法请求特定项的 UIElement。
请求给定索引的元素将导致该元素被标记为“正在使用”的布局传递。 如果该元素尚不存在,则会实现并自动准备好使用它(例如,在 DataTemplate 中定义的 UI 树膨胀、处理任何数据绑定等)。 否则,将从现有实例池中检索它。
在每个度量值传递结束时,任何未标记为“正在使用”的现有已实现元素都将自动被视为可供重复使用,除非通过 GetOrCreateElementAt 方法检索元素时使用了 SuppressAutoRecycle 选项。 框架会自动将其移动到回收池并使其可用。 随后可能会拉取它供其他容器使用。 框架会尽量避免这种情况,因为存在一些与重新父级元素相关的成本。
如果虚拟化布局知道每个度量值的开头,元素将不再属于实现 rect,则它可以优化其重用。 而不是依赖于框架的默认行为。 布局可以使用 RecycleElement 方法抢先地将元素移动到回收池。 在请求新元素之前调用此方法会导致当布局稍后针对尚未与元素关联的索引发出 GetOrCreateElementAt 请求时,这些现有元素将可用。
VirtualizingLayoutContext 提供了两个专为创建内容相关布局的布局作者设计的附加属性。 稍后将更详细地讨论它们。
- 一个 RecommendedAnchorIndex,它提供布局的可选输入。
- 布局的可选输出的 LayoutOrigin。
依赖于数据的虚拟化布局
如果知道每个项的大小应该是什么,而无需测量要显示的内容,虚拟化布局会更容易。 在本文档中,我们只需将此类别的虚拟化 布局称为数据布局 ,因为它们通常涉及检查数据。 根据数据,应用可能会选取具有已知大小的视觉表示形式,可能是因为它属于数据部分,或者以前由设计决定。
一般方法是让布局:
- 计算每个项的大小和位置。
- 作为 MeasureOverride 的一部分:
- 使用 RealizationRect 确定应出现在视区中的项。
- 检索应使用 GetOrCreateElementAt 方法表示项的 UIElement。
- 使用预计算大小度量 UIElement。
- 作为 ArrangeOverride 的一部分, 使用 预先计算的位置排列每个实现的 UIElement。
注释
数据布局方法通常与 数据虚拟化不兼容。 具体而言,加载到内存中的唯一数据是填充用户可见内容所需的数据。 数据虚拟化不是指用户滚动到该数据保持驻留的位置时延迟或增量加载数据。 相反,它指的是当项从内存中释放时,它们被滚动出视图。 在数据布局中检查每个数据项的数据布局可防止数据虚拟化按预期工作。 例外情况是一个布局,如 UniformGridLayout,它假定所有内容的大小都相同。
小窍门
如果要为控件库创建自定义控件,该控件库将在各种情况下供其他人使用,则数据布局可能不是一个选项。
示例:Xbox 活动源布局
Xbox 活动源的 UI 使用重复模式,其中每行都有一个宽磁贴,后跟在后续行上反转的两个窄磁贴。 在此布局中,每个项的大小都是项在数据集中的位置和磁贴的已知大小(宽与窄)的函数。
下面的代码演示了活动源的自定义虚拟化 UI 是什么,以说明对 数据布局可能采用的一般方法。
小窍门
如果已安装 WinUI 3 库应用,请单击此处打开该应用并查看 ItemsRepeater。 从 Microsoft 应用商店 获取应用,或在 GitHub 上获取源代码。
Implementation
/// <summary>
/// This is a custom layout that displays elements in two different sizes
/// wide (w) and narrow (n). There are two types of rows
/// odd rows - narrow narrow wide
/// even rows - wide narrow narrow
/// This pattern repeats.
/// </summary>
public class ActivityFeedLayout : VirtualizingLayout // STEP #1 Inherit from base attached layout
{
// STEP #2 - Parameterize the layout
#region Layout parameters
// We'll cache copies of the dependency properties to avoid calling GetValue during layout since that
// can be quite expensive due to the number of times we'd end up calling these.
private double _rowSpacing;
private double _colSpacing;
private Size _minItemSize = Size.Empty;
/// <summary>
/// Gets or sets the size of the whitespace gutter to include between rows
/// </summary>
public double RowSpacing
{
get { return _rowSpacing; }
set { SetValue(RowSpacingProperty, value); }
}
/// <summary>
/// Gets or sets the size of the whitespace gutter to include between items on the same row
/// </summary>
public double ColumnSpacing
{
get { return _colSpacing; }
set { SetValue(ColumnSpacingProperty, value); }
}
public Size MinItemSize
{
get { return _minItemSize; }
set { SetValue(MinItemSizeProperty, value); }
}
public static readonly DependencyProperty RowSpacingProperty =
DependencyProperty.Register(
nameof(RowSpacing),
typeof(double),
typeof(ActivityFeedLayout),
new PropertyMetadata(0, OnPropertyChanged));
public static readonly DependencyProperty ColumnSpacingProperty =
DependencyProperty.Register(
nameof(ColumnSpacing),
typeof(double),
typeof(ActivityFeedLayout),
new PropertyMetadata(0, OnPropertyChanged));
public static readonly DependencyProperty MinItemSizeProperty =
DependencyProperty.Register(
nameof(MinItemSize),
typeof(Size),
typeof(ActivityFeedLayout),
new PropertyMetadata(Size.Empty, OnPropertyChanged));
private static void OnPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
var layout = obj as ActivityFeedLayout;
if (args.Property == RowSpacingProperty)
{
layout._rowSpacing = (double)args.NewValue;
}
else if (args.Property == ColumnSpacingProperty)
{
layout._colSpacing = (double)args.NewValue;
}
else if (args.Property == MinItemSizeProperty)
{
layout._minItemSize = (Size)args.NewValue;
}
else
{
throw new InvalidOperationException("Don't know what you are talking about!");
}
layout.InvalidateMeasure();
}
#endregion
#region Setup / teardown // STEP #3: Initialize state
protected override void InitializeForContextCore(VirtualizingLayoutContext context)
{
base.InitializeForContextCore(context);
var state = context.LayoutState as ActivityFeedLayoutState;
if (state == null)
{
// Store any state we might need since (in theory) the layout could be in use by multiple
// elements simultaneously
// In reality for the Xbox Activity Feed there's probably only a single instance.
context.LayoutState = new ActivityFeedLayoutState();
}
}
protected override void UninitializeForContextCore(VirtualizingLayoutContext context)
{
base.UninitializeForContextCore(context);
// clear any state
context.LayoutState = null;
}
#endregion
#region Layout // STEP #4,5 - Measure and Arrange
protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize)
{
if (this.MinItemSize == Size.Empty)
{
var firstElement = context.GetOrCreateElementAt(0);
firstElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
// setting the member value directly to skip invalidating layout
this._minItemSize = firstElement.DesiredSize;
}
// Determine which rows need to be realized. We know every row will have the same height and
// only contain 3 items. Use that to determine the index for the first and last item that
// will be within that realization rect.
var firstRowIndex = Math.Max(
(int)(context.RealizationRect.Y / (this.MinItemSize.Height + this.RowSpacing)) - 1,
0);
var lastRowIndex = Math.Min(
(int)(context.RealizationRect.Bottom / (this.MinItemSize.Height + this.RowSpacing)) + 1,
(int)(context.ItemCount / 3));
// Determine which items will appear on those rows and what the rect will be for each item
var state = context.LayoutState as ActivityFeedLayoutState;
state.LayoutRects.Clear();
// Save the index of the first realized item. We'll use it as a starting point during arrange.
state.FirstRealizedIndex = firstRowIndex * 3;
// ideal item width that will expand/shrink to fill available space
double desiredItemWidth = Math.Max(this.MinItemSize.Width, (availableSize.Width - this.ColumnSpacing * 3) / 4);
// Foreach item between the first and last index,
// Call GetElementOrCreateElementAt which causes an element to either be realized or retrieved
// from a recycle pool
// Measure the element using an appropriate size
//
// Any element that was previously realized which we don't retrieve in this pass (via a call to
// GetElementOrCreateAt) will be automatically cleared and set aside for later re-use.
// Note: While this work fine, it does mean that more elements than are required may be
// created because it isn't until after our MeasureOverride completes that the unused elements
// will be recycled and available to use. We could avoid this by choosing to track the first/last
// index from the previous layout pass. The diff between the previous range and current range
// would represent the elements that we can pre-emptively make available for re-use by calling
// context.RecycleElement(element).
for (int rowIndex = firstRowIndex; rowIndex < lastRowIndex; rowIndex++)
{
int firstItemIndex = rowIndex * 3;
var boundsForCurrentRow = CalculateLayoutBoundsForRow(rowIndex, desiredItemWidth);
for (int columnIndex = 0; columnIndex < 3; columnIndex++)
{
var index = firstItemIndex + columnIndex;
var rect = boundsForCurrentRow[index % 3];
var container = context.GetOrCreateElementAt(index);
container.Measure(
new Size(boundsForCurrentRow[columnIndex].Width, boundsForCurrentRow[columnIndex].Height));
state.LayoutRects.Add(boundsForCurrentRow[columnIndex]);
}
}
// Calculate and return the size of all the content (realized or not) by figuring out
// what the bottom/right position of the last item would be.
var extentHeight = ((int)(context.ItemCount / 3) - 1) * (this.MinItemSize.Height + this.RowSpacing) + this.MinItemSize.Height;
// Report this as the desired size for the layout
return new Size(desiredItemWidth * 4 + this.ColumnSpacing * 2, extentHeight);
}
protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize)
{
// walk through the cache of containers and arrange
var state = context.LayoutState as ActivityFeedLayoutState;
var virtualContext = context as VirtualizingLayoutContext;
int currentIndex = state.FirstRealizedIndex;
foreach (var arrangeRect in state.LayoutRects)
{
var container = virtualContext.GetOrCreateElementAt(currentIndex);
container.Arrange(arrangeRect);
currentIndex++;
}
return finalSize;
}
#endregion
#region Helper methods
private Rect[] CalculateLayoutBoundsForRow(int rowIndex, double desiredItemWidth)
{
var boundsForRow = new Rect[3];
var yoffset = rowIndex * (this.MinItemSize.Height + this.RowSpacing);
boundsForRow[0].Y = boundsForRow[1].Y = boundsForRow[2].Y = yoffset;
boundsForRow[0].Height = boundsForRow[1].Height = boundsForRow[2].Height = this.MinItemSize.Height;
if (rowIndex % 2 == 0)
{
// Left tile (narrow)
boundsForRow[0].X = 0;
boundsForRow[0].Width = desiredItemWidth;
// Middle tile (narrow)
boundsForRow[1].X = boundsForRow[0].Right + this.ColumnSpacing;
boundsForRow[1].Width = desiredItemWidth;
// Right tile (wide)
boundsForRow[2].X = boundsForRow[1].Right + this.ColumnSpacing;
boundsForRow[2].Width = desiredItemWidth * 2 + this.ColumnSpacing;
}
else
{
// Left tile (wide)
boundsForRow[0].X = 0;
boundsForRow[0].Width = (desiredItemWidth * 2 + this.ColumnSpacing);
// Middle tile (narrow)
boundsForRow[1].X = boundsForRow[0].Right + this.ColumnSpacing;
boundsForRow[1].Width = desiredItemWidth;
// Right tile (narrow)
boundsForRow[2].X = boundsForRow[1].Right + this.ColumnSpacing;
boundsForRow[2].Width = desiredItemWidth;
}
return boundsForRow;
}
#endregion
}
internal class ActivityFeedLayoutState
{
public int FirstRealizedIndex { get; set; }
/// <summary>
/// List of layout bounds for items starting with the
/// FirstRealizedIndex.
/// </summary>
public List<Rect> LayoutRects
{
get
{
if (_layoutRects == null)
{
_layoutRects = new List<Rect>();
}
return _layoutRects;
}
}
private List<Rect> _layoutRects;
}
(可选)管理项到 UIElement 映射
默认情况下, VirtualizingLayoutContext 在实现的元素和它们所表示的数据源中的索引之间保持映射。 布局可以选择通过 GetOrCreateElementAt 方法检索元素时始终请求 SuppressAutoRecycle 选项来管理此映射本身,从而阻止默认自动回收行为。 例如,如果仅当滚动限制为一个方向时,布局可能会选择执行此作,并且它考虑的项目始终是连续的(即知道第一个和最后一个元素的索引足以知道应实现的所有元素)。
示例:Xbox 活动源度量值
以下代码片段显示了可以添加到上一示例中 MeasureOverride 以管理映射的其他逻辑。
protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize)
{
//...
// Determine which items will appear on those rows and what the rect will be for each item
var state = context.LayoutState as ActivityFeedLayoutState;
state.LayoutRects.Clear();
// Recycle previously realized elements that we know we won't need so that they can be used to
// fill in gaps without requiring us to realize additional elements.
var newFirstRealizedIndex = firstRowIndex * 3;
var newLastRealizedIndex = lastRowIndex * 3 + 3;
for (int i = state.FirstRealizedIndex; i < newFirstRealizedIndex; i++)
{
context.RecycleElement(state.IndexToElementMap.Get(i));
state.IndexToElementMap.Clear(i);
}
for (int i = state.LastRealizedIndex; i < newLastRealizedIndex; i++)
{
context.RecycleElement(context.IndexElementMap.Get(i));
state.IndexToElementMap.Clear(i);
}
// ...
// Foreach item between the first and last index,
// Call GetElementOrCreateElementAt which causes an element to either be realized or retrieved
// from a recycle pool
// Measure the element using an appropriate size
//
for (int rowIndex = firstRowIndex; rowIndex < lastRowIndex; rowIndex++)
{
int firstItemIndex = rowIndex * 3;
var boundsForCurrentRow = CalculateLayoutBoundsForRow(rowIndex, desiredItemWidth);
for (int columnIndex = 0; columnIndex < 3; columnIndex++)
{
var index = firstItemIndex + columnIndex;
var rect = boundsForCurrentRow[index % 3];
UIElement container = null;
if (state.IndexToElementMap.Contains(index))
{
container = state.IndexToElementMap.Get(index);
}
else
{
container = context = context.GetOrCreateElementAt(index, ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle);
state.IndexToElementMap.Add(index, container);
}
container.Measure(
new Size(boundsForCurrentRow[columnIndex].Width, boundsForCurrentRow[columnIndex].Height));
state.LayoutRects.Add(boundsForCurrentRow[columnIndex]);
}
}
// ...
}
internal class ActivityFeedLayoutState
{
// ...
Dictionary<int, UIElement> IndexToElementMap { get; set; }
// ...
}
依赖于内容的虚拟化布局
如果首先必须度量项的 UI 内容以找出其确切大小,则它是 依赖于内容的布局。 还可以将其视为一种布局,其中每个项必须调整自身大小,而不是告知项目大小布局。 虚拟化属于此类别的布局更为复杂。
注释
依赖于内容的布局不会(不应)破坏数据虚拟化。
估计
依赖内容的布局依赖于估计来猜测未实现的内容的大小和实现的内容的位置。 随着这些估计的变化,它将导致已实现的内容定期在可滚动区域中移动位置。 如果不缓解,这可能会导致非常令人沮丧和不和谐的用户体验。 此处讨论了潜在问题和缓解措施。
注释
考虑每个项并知道所有项的确切大小、已实现或不实现的数据布局,其位置可以完全避免这些问题。
滚动定位
XAML 提供一种机制,通过实现 IScrollAnchorPovider 接口,让滚动控件支持滚动定位,从而缓解突然视区转移。 当用户作内容时,滚动控件会不断从选择被跟踪的候选项集中选择一个元素。 如果定位点元素的位置在布局期间移动,则滚动控件会自动移动其视区以维护视区。
提供给布局的 RecommendedAnchorIndex 的值可能反映当前由滚动控件选择的定位点元素。 或者,如果开发人员显式请求在 ItemsRepeater 上使用 GetOrCreateElement 方法实现索引的元素,则会在下一个布局传递中将该索引指定为 RecommendedAnchorIndex。 这使布局可以针对开发人员实现元素的可能场景做好准备,并随后请求通过 StartBringIntoView 方法将其引入视图。
RecommendedAnchorIndex 是数据源中项的索引,内容依赖布局在估计其项的位置时应首先定位。 它应用作定位其他已实现项的起点。
对 ScrollBar 的影响
即使使用滚动定位,如果布局的估计变化很大,可能是由于内容大小的显著变化,那么 ScrollBar 的拇指位置似乎会跳来跳去。 如果拇指在拖动鼠标指针时没有跟踪鼠标指针的位置,则用户可能会很不和谐。
布局的估计越准确,那么用户看到 ScrollBar 的拇指跳跃的可能性就越小。
布局更正
应准备好依赖内容的布局,以便根据现实来合理化其估计。 例如,当用户滚动到内容顶部并且布局实现第一个元素时,它可能会发现元素相对于它启动的元素的预期位置会导致它出现在 (x:0, y:0) 以外的某个位置。 发生这种情况时,布局可以使用 LayoutOrigin 属性设置计算为新布局原点的位置。 净结果类似于滚动定位,其中滚动控件的视区会自动调整为考虑到布局所报告的内容位置。
断开连接的视区
从布局的 MeasureOverride 方法返回的大小表示每个连续布局可能更改的内容大小的最佳猜测。 当用户滚动时,将使用更新的 RealizationRect 持续重新评估布局。
如果用户快速拖动拇指,那么视区可能从布局的角度来看,似乎使以前的位置与现在的位置不重叠的大型跳跃。 这是因为滚动的异步性质。 应用还可以使用布局来请求为当前未实现的项引入视图的元素,并估计位于布局跟踪的当前范围之外。
当布局发现其猜测不正确并且/或看到意外视区移位时,它需要重新定位其起始位置。 作为 XAML 控件一部分交付的虚拟化布局是作为依赖内容的布局开发的,因为它们对要显示的内容的性质施加了较少的限制。
示例:Variable-Sized 项的简单虚拟化堆栈布局
下面的示例演示了可变大小的项的简单堆栈布局,这些布局:
- 支持 UI 虚拟化,
- 使用估计来猜测未实现的项目的大小,
- 了解潜在的不连续视区转移,并且
- 应用布局更正以考虑这些班次。
用法:标记
<ScrollViewer>
<ItemsRepeater x:Name="repeater" >
<ItemsRepeater.Layout>
<local:VirtualizingStackLayout />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:Key="item">
<UserControl IsTabStop="True" UseSystemFocusVisuals="True" Margin="5">
<StackPanel BorderThickness="1" Background="LightGray" Margin="5">
<Image x:Name="recipeImage" Source="{Binding ImageUri}" Width="100" Height="100"/>
<TextBlock x:Name="recipeDescription"
Text="{Binding Description}"
TextWrapping="Wrap"
Margin="10" />
</StackPanel>
</UserControl>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ScrollViewer>
Codebehind:Main.cs
string _lorem = @"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam laoreet erat vel massa rutrum, eget mollis massa vulputate. Vivamus semper augue leo, eget faucibus nulla mattis nec. Donec scelerisque lacus at dui ultricies, eget auctor ipsum placerat. Integer aliquet libero sed nisi eleifend, nec rutrum arcu lacinia. Sed a sem et ante gravida congue sit amet ut augue. Donec quis pellentesque urna, non finibus metus. Proin sed ornare tellus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam laoreet erat vel massa rutrum, eget mollis massa vulputate. Vivamus semper augue leo, eget faucibus nulla mattis nec. Donec scelerisque lacus at dui ultricies, eget auctor ipsum placerat. Integer aliquet libero sed nisi eleifend, nec rutrum arcu lacinia. Sed a sem et ante gravida congue sit amet ut augue. Donec quis pellentesque urna, non finibus metus. Proin sed ornare tellus.";
var rnd = new Random();
var data = new ObservableCollection<Recipe>(Enumerable.Range(0, 300).Select(k =>
new Recipe
{
ImageUri = new Uri(string.Format("ms-appx:///Images/recipe{0}.png", k % 8 + 1)),
Description = k + " - " + _lorem.Substring(0, rnd.Next(50, 350))
}));
repeater.ItemsSource = data;
代码:VirtualizingStackLayout.cs
// This is a sample layout that stacks elements one after
// the other where each item can be of variable height. This is
// also a virtualizing layout - we measure and arrange only elements
// that are in the viewport. Not measuring/arranging all elements means
// that we do not have the complete picture and need to estimate sometimes.
// For example the size of the layout (extent) is an estimation based on the
// average heights we have seen so far. Also, if you drag the mouse thumb
// and yank it quickly, then we estimate what goes in the new viewport.
// The layout caches the bounds of everything that are in the current viewport.
// During measure, we might get a suggested anchor (or start index), we use that
// index to start and layout the rest of the items in the viewport relative to that
// index. Note that since we are estimating, we can end up with negative origin when
// the viewport is somewhere in the middle of the extent. This is achieved by setting the
// LayoutOrigin property on the context. Once this is set, future viewport will account
// for the origin.
public class VirtualizingStackLayout : VirtualizingLayout
{
// Estimation state
List<double> m_estimationBuffer = Enumerable.Repeat(0d, 100).ToList();
int m_numItemsUsedForEstimation = 0;
double m_totalHeightForEstimation = 0;
// State to keep track of realized bounds
int m_firstRealizedDataIndex = 0;
List<Rect> m_realizedElementBounds = new List<Rect>();
Rect m_lastExtent = new Rect();
protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize)
{
var viewport = context.RealizationRect;
DebugTrace("MeasureOverride: Viewport " + viewport);
// Remove bounds for elements that are now outside the viewport.
// Proactive recycling elements means we can reuse it during this measure pass again.
RemoveCachedBoundsOutsideViewport(viewport);
// Find the index of the element to start laying out from - the anchor
int startIndex = GetStartIndex(context, availableSize);
// Measure and layout elements starting from the start index, forward and backward.
Generate(context, availableSize, startIndex, forward:true);
Generate(context, availableSize, startIndex, forward:false);
// Estimate the extent size. Note that this can have a non 0 origin.
m_lastExtent = EstimateExtent(context, availableSize);
context.LayoutOrigin = new Point(m_lastExtent.X, m_lastExtent.Y);
return new Size(m_lastExtent.Width, m_lastExtent.Height);
}
protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize)
{
DebugTrace("ArrangeOverride: Viewport" + context.RealizationRect);
for (int realizationIndex = 0; realizationIndex < m_realizedElementBounds.Count; realizationIndex++)
{
int currentDataIndex = m_firstRealizedDataIndex + realizationIndex;
DebugTrace("Arranging " + currentDataIndex);
// Arrange the child. If any alignment needs to be done, it
// can be done here.
var child = context.GetOrCreateElementAt(currentDataIndex);
var arrangeBounds = m_realizedElementBounds[realizationIndex];
arrangeBounds.X -= m_lastExtent.X;
arrangeBounds.Y -= m_lastExtent.Y;
child.Arrange(arrangeBounds);
}
return finalSize;
}
// The data collection has changed, since we are maintaining the bounds of elements
// in the viewport, we will update the list to account for the collection change.
protected override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args)
{
InvalidateMeasure();
if (m_realizedElementBounds.Count > 0)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
break;
case NotifyCollectionChangedAction.Replace:
OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count);
OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
break;
case NotifyCollectionChangedAction.Remove:
OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count);
break;
case NotifyCollectionChangedAction.Reset:
m_realizedElementBounds.Clear();
m_firstRealizedDataIndex = 0;
break;
default:
throw new NotImplementedException();
}
}
}
// Figure out which index to use as the anchor and start laying out around it.
private int GetStartIndex(VirtualizingLayoutContext context, Size availableSize)
{
int startDataIndex = -1;
var recommendedAnchorIndex = context.RecommendedAnchorIndex;
bool isSuggestedAnchorValid = recommendedAnchorIndex != -1;
if (isSuggestedAnchorValid)
{
if (IsRealized(recommendedAnchorIndex))
{
startDataIndex = recommendedAnchorIndex;
}
else
{
ClearRealizedRange();
startDataIndex = recommendedAnchorIndex;
}
}
else
{
// Find the first realized element that is visible in the viewport.
startDataIndex = GetFirstRealizedDataIndexInViewport(context.RealizationRect);
if (startDataIndex < 0)
{
startDataIndex = EstimateIndexForViewport(context.RealizationRect, context.ItemCount);
ClearRealizedRange();
}
}
// We have an anchorIndex, realize and measure it and
// figure out its bounds.
if (startDataIndex != -1 & context.ItemCount > 0)
{
if (m_realizedElementBounds.Count == 0)
{
m_firstRealizedDataIndex = startDataIndex;
}
var newAnchor = EnsureRealized(startDataIndex);
DebugTrace("Measuring start index " + startDataIndex);
var desiredSize = MeasureElement(context, startDataIndex, availableSize);
var bounds = new Rect(
0,
newAnchor ?
(m_totalHeightForEstimation / m_numItemsUsedForEstimation) * startDataIndex : GetCachedBoundsForDataIndex(startDataIndex).Y,
availableSize.Width,
desiredSize.Height);
SetCachedBoundsForDataIndex(startDataIndex, bounds);
}
return startDataIndex;
}
private void Generate(VirtualizingLayoutContext context, Size availableSize, int anchorDataIndex, bool forward)
{
// Generate forward or backward from anchorIndex until we hit the end of the viewport
int step = forward ? 1 : -1;
int previousDataIndex = anchorDataIndex;
int currentDataIndex = previousDataIndex + step;
var viewport = context.RealizationRect;
while (IsDataIndexValid(currentDataIndex, context.ItemCount) &&
ShouldContinueFillingUpSpace(previousDataIndex, forward, viewport))
{
EnsureRealized(currentDataIndex);
DebugTrace("Measuring " + currentDataIndex);
var desiredSize = MeasureElement(context, currentDataIndex, availableSize);
var previousBounds = GetCachedBoundsForDataIndex(previousDataIndex);
Rect currentBounds = new Rect(0,
forward ? previousBounds.Y + previousBounds.Height : previousBounds.Y - desiredSize.Height,
availableSize.Width,
desiredSize.Height);
SetCachedBoundsForDataIndex(currentDataIndex, currentBounds);
previousDataIndex = currentDataIndex;
currentDataIndex += step;
}
}
// Remove bounds that are outside the viewport, leaving one extra since our
// generate stops after generating one extra to know that we are outside the
// viewport.
private void RemoveCachedBoundsOutsideViewport(Rect viewport)
{
int firstRealizedIndexInViewport = 0;
while (firstRealizedIndexInViewport < m_realizedElementBounds.Count &&
!Intersects(m_realizedElementBounds[firstRealizedIndexInViewport], viewport))
{
firstRealizedIndexInViewport++;
}
int lastRealizedIndexInViewport = m_realizedElementBounds.Count - 1;
while (lastRealizedIndexInViewport >= 0 &&
!Intersects(m_realizedElementBounds[lastRealizedIndexInViewport], viewport))
{
lastRealizedIndexInViewport--;
}
if (firstRealizedIndexInViewport > 0)
{
m_firstRealizedDataIndex += firstRealizedIndexInViewport;
m_realizedElementBounds.RemoveRange(0, firstRealizedIndexInViewport);
}
if (lastRealizedIndexInViewport >= 0 && lastRealizedIndexInViewport < m_realizedElementBounds.Count - 2)
{
m_realizedElementBounds.RemoveRange(lastRealizedIndexInViewport + 2, m_realizedElementBounds.Count - lastRealizedIndexInViewport - 3);
}
}
private bool Intersects(Rect bounds, Rect viewport)
{
return !(bounds.Bottom < viewport.Top ||
bounds.Top > viewport.Bottom);
}
private bool ShouldContinueFillingUpSpace(int dataIndex, bool forward, Rect viewport)
{
var bounds = GetCachedBoundsForDataIndex(dataIndex);
return forward ?
bounds.Y < viewport.Bottom :
bounds.Y > viewport.Top;
}
private bool IsDataIndexValid(int currentDataIndex, int itemCount)
{
return currentDataIndex >= 0 && currentDataIndex < itemCount;
}
private int EstimateIndexForViewport(Rect viewport, int dataCount)
{
double averageHeight = m_totalHeightForEstimation / m_numItemsUsedForEstimation;
int estimatedIndex = (int)(viewport.Top / averageHeight);
// clamp to an index within the collection
estimatedIndex = Math.Max(0, Math.Min(estimatedIndex, dataCount));
return estimatedIndex;
}
private int GetFirstRealizedDataIndexInViewport(Rect viewport)
{
int index = -1;
if (m_realizedElementBounds.Count > 0)
{
for (int i = 0; i < m_realizedElementBounds.Count; i++)
{
if (m_realizedElementBounds[i].Y < viewport.Bottom &&
m_realizedElementBounds[i].Bottom > viewport.Top)
{
index = m_firstRealizedDataIndex + i;
break;
}
}
}
return index;
}
private Size MeasureElement(VirtualizingLayoutContext context, int index, Size availableSize)
{
var child = context.GetOrCreateElementAt(index);
child.Measure(availableSize);
int estimationBufferIndex = index % m_estimationBuffer.Count;
bool alreadyMeasured = m_estimationBuffer[estimationBufferIndex] != 0;
if (!alreadyMeasured)
{
m_numItemsUsedForEstimation++;
}
m_totalHeightForEstimation -= m_estimationBuffer[estimationBufferIndex];
m_totalHeightForEstimation += child.DesiredSize.Height;
m_estimationBuffer[estimationBufferIndex] = child.DesiredSize.Height;
return child.DesiredSize;
}
private bool EnsureRealized(int dataIndex)
{
if (!IsRealized(dataIndex))
{
int realizationIndex = RealizationIndex(dataIndex);
Debug.Assert(dataIndex == m_firstRealizedDataIndex - 1 ||
dataIndex == m_firstRealizedDataIndex + m_realizedElementBounds.Count ||
m_realizedElementBounds.Count == 0);
if (realizationIndex == -1)
{
m_realizedElementBounds.Insert(0, new Rect());
}
else
{
m_realizedElementBounds.Add(new Rect());
}
if (m_firstRealizedDataIndex > dataIndex)
{
m_firstRealizedDataIndex = dataIndex;
}
return true;
}
return false;
}
// Figure out the extent of the layout by getting the number of items remaining
// above and below the realized elements and getting an estimation based on
// average item heights seen so far.
private Rect EstimateExtent(VirtualizingLayoutContext context, Size availableSize)
{
double averageHeight = m_totalHeightForEstimation / m_numItemsUsedForEstimation;
Rect extent = new Rect(0, 0, availableSize.Width, context.ItemCount * averageHeight);
if (context.ItemCount > 0 && m_realizedElementBounds.Count > 0)
{
extent.Y = m_firstRealizedDataIndex == 0 ?
m_realizedElementBounds[0].Y :
m_realizedElementBounds[0].Y - (m_firstRealizedDataIndex - 1) * averageHeight;
int lastRealizedIndex = m_firstRealizedDataIndex + m_realizedElementBounds.Count;
if (lastRealizedIndex == context.ItemCount - 1)
{
var lastBounds = m_realizedElementBounds[m_realizedElementBounds.Count - 1];
extent.Y = lastBounds.Bottom;
}
else
{
var lastBounds = m_realizedElementBounds[m_realizedElementBounds.Count - 1];
int lastRealizedDataIndex = m_firstRealizedDataIndex + m_realizedElementBounds.Count;
int numItemsAfterLastRealizedIndex = context.ItemCount - lastRealizedDataIndex;
extent.Height = lastBounds.Bottom + numItemsAfterLastRealizedIndex * averageHeight - extent.Y;
}
}
DebugTrace("Extent " + extent + " with average height " + averageHeight);
return extent;
}
private bool IsRealized(int dataIndex)
{
int realizationIndex = dataIndex - m_firstRealizedDataIndex;
return realizationIndex >= 0 && realizationIndex < m_realizedElementBounds.Count;
}
// Index in the m_realizedElementBounds collection
private int RealizationIndex(int dataIndex)
{
return dataIndex - m_firstRealizedDataIndex;
}
private void OnItemsAdded(int index, int count)
{
// Using the old indexes here (before it was updated by the collection change)
// if the insert data index is between the first and last realized data index, we need
// to insert items.
int lastRealizedDataIndex = m_firstRealizedDataIndex + m_realizedElementBounds.Count - 1;
int newStartingIndex = index;
if (newStartingIndex > m_firstRealizedDataIndex &&
newStartingIndex <= lastRealizedDataIndex)
{
// Inserted within the realized range
int insertRangeStartIndex = newStartingIndex - m_firstRealizedDataIndex;
for (int i = 0; i < count; i++)
{
// Insert null (sentinel) here instead of an element, that way we do not
// end up creating a lot of elements only to be thrown out in the next layout.
int insertRangeIndex = insertRangeStartIndex + i;
int dataIndex = newStartingIndex + i;
// This is to keep the contiguousness of the mapping
m_realizedElementBounds.Insert(insertRangeIndex, new Rect());
}
}
else if (index <= m_firstRealizedDataIndex)
{
// Items were inserted before the realized range.
// We need to update m_firstRealizedDataIndex;
m_firstRealizedDataIndex += count;
}
}
private void OnItemsRemoved(int index, int count)
{
int lastRealizedDataIndex = m_firstRealizedDataIndex + m_realizedElementBounds.Count - 1;
int startIndex = Math.Max(m_firstRealizedDataIndex, index);
int endIndex = Math.Min(lastRealizedDataIndex, index + count - 1);
bool removeAffectsFirstRealizedDataIndex = (index <= m_firstRealizedDataIndex);
if (endIndex >= startIndex)
{
ClearRealizedRange(RealizationIndex(startIndex), endIndex - startIndex + 1);
}
if (removeAffectsFirstRealizedDataIndex &&
m_firstRealizedDataIndex != -1)
{
m_firstRealizedDataIndex -= count;
}
}
private void ClearRealizedRange(int startRealizedIndex, int count)
{
m_realizedElementBounds.RemoveRange(startRealizedIndex, count);
if (startRealizedIndex == 0)
{
m_firstRealizedDataIndex = m_realizedElementBounds.Count == 0 ? 0 : m_firstRealizedDataIndex + count;
}
}
private void ClearRealizedRange()
{
m_realizedElementBounds.Clear();
m_firstRealizedDataIndex = 0;
}
private Rect GetCachedBoundsForDataIndex(int dataIndex)
{
return m_realizedElementBounds[RealizationIndex(dataIndex)];
}
private void SetCachedBoundsForDataIndex(int dataIndex, Rect bounds)
{
m_realizedElementBounds[RealizationIndex(dataIndex)] = bounds;
}
private Rect GetCachedBoundsForRealizationIndex(int relativeIndex)
{
return m_realizedElementBounds[relativeIndex];
}
void DebugTrace(string message, params object[] args)
{
Debug.WriteLine(message, args);
}
}