65.9K
CodeProject 正在变化。 阅读更多。
Home

概念性子元素:WPF 中一个强大的新概念

starIconstarIconstarIconstarIconstarIcon

5.00/5 (33投票s)

2008 年 4 月 6 日

BSD

32分钟阅读

viewsIcon

235255

downloadIcon

3189

本文介绍了一种新方法,通过这种方法,一个元素可以在保持与子元素的概念性父子关系的同时,去除其与子元素的视觉和逻辑关系。

引言

您是否曾希望某个元素能有一个既非视觉也非逻辑的父元素?好吧,让我换种方式问……您是否曾见过以下任一异常?

ArgumentException: Specified Visual is already a child of 
another Visual or the root of a CompositionTarget.

InvalidOperationException: Specified element is already 
the logical child of another element. Disconnect it first.

正如这些错误消息所示,在 Windows Presentation Foundation (WPF) 中,一个元素最多只能有一个视觉父元素和最多一个逻辑父元素。这实际上非常重要,因为它确保了视觉树和逻辑树的结构良好。

虽然我完全承认这一要求的重要性,但肯定也遇到过这样的情况,我真的希望某个面板仅仅作为某个元素的“概念性”父元素,以便另一个面板能够实际承担视觉和/或逻辑上的父级角色。

Contoso CaseBuilder 2003

我第一次遇到这个问题大约是 5 年前,当时正在为 Microsoft 的 Professional Developer's Conference (PDC) 2003 准备一个演示。如果您参加了 PDC '03,您可能会回想起在开幕主题演讲中展示的应用程序。

我从这篇非常有信息量的文章中截取了这个旧的盗版图片。好吧,我 Actually 不懂日语,也许您也不懂,但如果您向下滚动大约一半,您会发现几张分别标有 WinFS+Avalon 和 WinFS 的图片。

旁注:如果您想知道 Avalon 是什么,它就是 WPF 在拥有一个名字时的称呼。而且,如果您想知道 WinFS 是什么,不用担心。它似乎是一项已死的图形系统技术。我编写了这个演示应用程序的 WinFS 部分,而且我 Actually 喜欢它。这个演示是 Avalon 的首次公开亮相,它很可能代表了 WinFS 的最后一次公开展示。(我宁愿相信 WinFS 的消亡与我的演示代码无关!)

回到正题……我希望,除了谈论 WinFS 之外,这篇文章还能说明这个应用程序如何通过演示视觉元素的布局到布局动画来展示 Avalon 强大的动画支持。

如您所知,WPF 平台原生不支持布局到布局动画。为什么?嗯,因为一个子元素只能有一个视觉父元素。也就是说,一个视觉子元素只能属于一个布局面板。因此,要在 WPF 中实现这种效果,您并不是真正在将一个子元素从一个布局面板动画到另一个布局面板。您是在伪装。

又一个旁注:对于那些好奇的人来说,此应用程序中布局到布局动画的错觉是通过一个名为 SendInTheClones() 的方法实现的。在该例程中,我们将一个布局面板中的所有元素克隆,将它们添加到第二个布局面板,应用变换使它们与第一个面板中的元素对齐,淡出实际元素,然后将克隆动画到它们在第二个面板中的自然位置。呃!这种痛苦的技术在 WPF 中仍被频繁使用。(我很高兴在名为Blendables Layout Mix的 WPF 新插件的预览版本中看到了对真实布局到布局动画的支持。除了 2D 布局,Blendables 团队还在名为Blendables 3D Mix的 CTP 版本中使 3D 布局更加易于访问。)

为什么现在提起这个?

最近,我的一位WPF 同道Sacha Barber,表示希望跟踪自定义面板的视觉子元素,以便他可以构建一个代表这些子元素的 3D 场景。由于面板的存在是为了为其子元素提供布局,因此您可能希望面板在 3D 空间而不是 2D 空间中定位元素,这是完全合理的。

不幸的是,在 WPF 中这并不容易做到。2D 视觉和 3D 视觉有完全独立的布局引擎。的确,已经做了很多出色的工作,以允许将 2D 视觉映射到 3D 空间,从而使这些视觉具有交互性(通过 Viewport2DVisual3D 元素),但这构建在 3D 布局引擎之上。Sacha 的目标是某种方式钩住 2D 布局引擎以获取视觉元素,然后使用 3D 布局引擎来定位这些视觉元素。

他自然而然地想到了可以重写他的面板中的 OnVisualChildrenChanged(),然后将每个新子元素用作 VisualBrush 的视觉,该 VisualBrush 可用于 3D 场景。他将他的想法带到小组中,以获取有关几个实现细节的建议。

在与 Sacha 一起研究这个概念时,另一位同道Josh Smith,立即看到了这样一个面板的潜力。他提出了自己巧妙的想法,即使用 Adorner 层来托管一个覆盖在面板之上的 Viewport3D。这将允许子元素在 2D 面板中进行测量和排列,然后每个子元素都可以呈现在 3D 场景中,再次使用 VisualBrush。作为 WPF 领域无可匹敌的摇滚明星,Josh 迅速组建了一个原型,并在 Sacha 的许可下撰文介绍了它

空间中的克隆

这是一个非常出色的实现,但元素的一个视觉画刷表示与在 3D 空间中拥有一个交互式 2D 元素是不同的。Josh 接着着手解决这个问题。他通过创建一个与面板内的子元素具有相同数据上下文和数据模板的 ContentPresenter 来有效地克隆元素。然后,这个新的 ContentPresenter 被用作 Viewport2DVisual3D 元素的视觉,该元素被添加到 Adorner 层中的 3D 场景中。

您可以在这里查看 Josh 的完全交互式示例。太棒了!

看到这种巧妙地使用 2D 面板在 3D 空间中呈现其子元素视图后,我快速搜索了一下,看看是否有人做过类似的事情。我发现 Pavan Podila 在将他的 ElementFlow 概念提升到面板时,几乎采用了完全相同的方法。显然,英雄所见略同!

遗留问题

观看 Sacha 和 Josh 在这个概念上的来回探讨,然后看到它的几个实际应用真的让我思绪万千!空间中的克隆的 Adorner 层方法,以及直接视觉子元素方法,都非常酷。不过,关于这个场景,有几个问题确实困扰着我(坦率地说,也困扰着 Josh 和 Sacha)。

  1. 为什么面板需要管理其子元素的重复表示?
  2. 对于复杂项模板,这种方法可能涉及大量的额外视觉元素!最好只是在 3D 场景中使用面板的实际子元素,这样每个子元素只有一个视觉实例。哦,对了,那些元素已经是面板的视觉子元素了。该死!

  3. 为什么 Children 集合中的项甚至应该是面板的视觉子元素?
  4. 这些子元素在概念上绝对不是视觉化的。在概念上,它们只是子元素……不是逻辑的……不是视觉的……只是概念性的。

    我的意思是,“在概念上它们不是视觉化的”是什么意思?简单地说,就是我们希望 2D 布局引擎将这些视觉元素视为面板的子元素,因为这意味着它在渲染面板时会尝试枚举和渲染这些子元素。

    我的意思是,“它们是概念性子元素”是什么意思?简单地说,就是我们仍然希望这些元素在概念上是子元素。也就是说,我们希望这些元素直接添加到面板的 Children 集合中。这使我们能够关键性地在传统 2D 面板的任何地方使用该面板。

这两个问题都源于这样一个事实:面板的子元素存储在一个名为 UIElementCollection 的特殊 UIElement 对象集合中。这个集合会自动将其成员设为面板的视觉子元素(并且通常也会将它们设为逻辑子元素)。当面板是项宿主时,UIElementCollection 还强制执行一个规则,即只有项容器生成器可以修改面板的 Children 集合。

旁注:如果您好奇的话,ItemContainerGenerator 类是一个特殊的框架类,它负责确保 ItemsControl 中的每个项都“包含”在该 ItemsControl 特定的 UIElement 中。有关更多信息,请参见:“I”代表项容器

“概念子元素”简介

您无法选择父母,所以不如选择您的孩子!

显然,我们需要一个不遵循 UIElementCollection 规则的面板类。我们仍然希望面板像其他所有面板一样工作。我们应该能够向面板添加元素,它们应该自动进入其 Children 集合。但是,这些子元素不应该自动成为面板的视觉子元素,甚至不是逻辑子元素。理想情况下,就框架而言,它们应该只是代表一组断开连接的视觉元素。我为这种新关系创造的术语是“概念子元素”

很明显,我们仍然应该能够将该面板用作项宿主。这意味着其 Children 集合必须与项容器生成器无缝协同工作,就像 UIElementCollection 为原生面板所做的那样。

ConceptualPanel、LogicalPanel 和 DisconnectedUIElementCollection

本文的其余部分包含关于我如何实现两个新面板 ConceptualPanelLogicalPanel 的详细信息。其中第一个面板支持这种纯概念性子元素集合的新概念。第二个面板派生自 ConceptualPanel,并将子元素提升为逻辑子元素。这两个面板都利用了一个名为 DisconnectedUIElementCollection 的特殊新集合。

如果您关心所有这些工作细节,请继续阅读。如果您只想查看正在运行的面板,现在可以跳转到本文结尾的“眼见为实”部分。

肮脏的细节

出于显而易见的原因,我认为不仅有必要解释为什么我创建了这些新面板,还要解释如何实现它们。您当然可以查看代码,但我还想要一个书面解释,以完全清楚我做出这些选择的原因。几乎每一行代码都是出于特定原因实现的。希望相关解释都能在这里找到。如果找不到,请告知我遗漏了什么。

总有改进的空间。一如既往,我正在以 BSD 开源许可证发布此代码,并将其放入公共领域,以便可以对其进行使用、压力测试和改进。如果您对这些类进行了值得注意的增强,请将更新后的代码发给我,以便我可以广泛传播。一如既往,我对建议、问题、反馈和其他评论持开放态度。下方有一个评论区,但如果您需要几行以上的空间,请给我发邮件。我将努力将所有此类反馈整合回本文中。

注意:您即将享用的饮品极烫!

接下来,只能称之为对 UIElementCollection 的一流黑客攻击!如果您不习惯黑客攻击框架的概念,应该立即停止阅读,并回到您定义明确的盒子中。以下区域仅适用于那些希望跳出盒子的人。 ;-)

我说的“一流黑客”是什么意思?简单来说。代码是为了利用框架内部的某些实现细节而编写的。我相信这是适用于 .NET 3.0 和 3.5 代码的一个非常可靠的解决方案。同时,我完全认识到微软拥有完全的权力,可以以其认为必要的方式更改其平台的内部工作原理。微软可能会以某种方式更改框架,从而导致此解决方案将来无法正常工作。我希望他们能认识到拥有一个将子元素视为纯概念性元素的真正 Panel 类所带来的绝对酷炫性。如果他们决定通过此处所述方法取消实现这一概念的能力,希望他们能原生支持它。 :-)

免责声明到此为止,现在,让我们开始攻击吧!

UIElementCollection 简介

要真正理解典型面板的子元素是如何维护的,我们必须先理解 UIElementCollection 的工作原理。以下是关键点:

1. Panel 的 Children 属性是 UIElementCollection 类型

Panel 类公开了一个名为 Children 的公共属性,其类型为 UIElementCollection。它重写了其 VisualChildrenCount 属性和 GetVisualChild() 方法,以将其 Children 集合的成员作为其视觉子元素返回。它还使用 GetVisualChild() 重写来为子元素提供 z 排序。

2. UIElementCollection 提供了一种非常方便的方式,可以为任何元素(不仅仅是面板)提供一组视觉(以及可选的逻辑)子元素

一个元素可以简单地创建 UIElementCollection 的一个实例,并将自己作为该集合的视觉父元素提供。然后,添加到集合中的任何 UIElement 都会自动添加为该元素的视觉子元素。如果创建集合时还指定了逻辑父元素,则子元素也会作为该元素的逻辑子元素添加。

3. UIElementCollection 将其成员存储在 VisualCollection 中

在内部,使用 VisualCollection 实例来存储 UIElementCollection 的成员。此实例在 UIElementCollection 的构造函数中创建。VisualCollection 是实际设置拥有元素和子元素之间的视觉父/子关系的类。它必须使用有效的视觉父元素创建。然后,UIElementCollection 通过添加对逻辑父/子关系的支持来扩展 VisualCollection 的功能。

4. UIElementCollection 在被 Panel 拥有时表现不同

当它被用来托管“项宿主”的子元素时,UIElementCollection 会阻止对其集合进行直接修改。(请参阅“P”代表面板,了解作为项宿主意味着什么[也称为项面板]。)如果面板不是项宿主,则允许直接修改 Children 集合。

5. UIElementCollection 有一个后门

UIElementCollection 上有一些特殊的内部方法,允许框架在面板是项宿主时修改其内部视觉集合。这些方法主要由关联的 ItemsControl 的项容器生成器访问。

6. UIElementCollection 假装是可扩展的

UIElementCollection 类将其大部分成员公开为虚拟函数。此外,Panel 类公开了一个名为 CreateUIElementCollection 的虚拟函数,可以重写它来创建一个派生自 UIElementCollection 并对其进行扩展的自定义集合。这意味着我们应该能够提供一个自定义集合,并通过重写适当的方法来监视其更改:AddClearInsertRemoveRemoveAt 等。

7. UIElementCollection 实际上不可扩展

虽然我们可以派生并使用自己的自定义 UIElementCollection 类在自定义面板中,但我现在参考上面的第 5 点……UIElementCollection 有一个后门! 结果是,只要我们仅在 ItemsControl 外部使用该面板,我们的方法重写就会完美工作。一旦我们尝试将我们的面板用作项宿主,框架就会绕过我们自定义 UIElementCollection 上的所有重写,并使用其后门来修改我们类的内部 VisualCollection。没错!视觉子元素将直接添加到我们的面板中,而不会给我们插入自定义逻辑的机会。

8. 我们还是要扩展 UIElementCollection!

现在应该很清楚了,框架不希望我们随意修改用于存储面板视觉子元素的内部 VisualCollection。它绕过我们的自定义逻辑(在数据绑定场景下)以使用其内部逻辑这一事实本身就说明了这一点。所以,让我们开始破解这个吧……

还记得我们的目标吗?我们要创建一个 Panel 类的派生类,它既不作为其子元素的视觉父元素,也不作为逻辑父元素。基本上,我们希望我们的 Children 集合的成员是未父化的。它们应该是纯概念性子元素。

避免父级义务并非易事!

我承认,在最终找到解决方案之前,我走了许多错误的道路。我的第一个想法是简单地监视 OnVisualChildrenChanged 并解除每个子元素的父级。这种方法的问题在于,框架不知道您正在解除内部 VisualCollection 的子元素的父级。由于它们在该集合中,因此这些元素被假定为其所有者的视觉子元素。当一个这样的元素从集合中移除时,框架会观察到断开的关系并引发异常。由于这通常通过后门发生,因此您无法插入自定义逻辑来规避异常。

我避免承担父级责任的其他许多尝试最终会成为最终工作解决方案的组成部分,所以我不会再列出我在此过程中遇到的其他障碍,以免让您感到厌烦。让我们来看看工作解决方案的架构。

现在开始,我将把这个解决方案当作我们一起进行的练习来构建。我们可以假装我们是完美的编码者,并且知道关于框架内部工作原理的所有必要知识(尽管实际的进展远没有如此优雅,并且需要深入思考……字面意义上的)。

介绍 DisconnectedUIElementCollection 类

现在,我认为很明显,我们构建的任何解决方案都需要基于自定义 UIElementCollection。我们首先定义一个名为 DisconnectedUIElementCollection 的类(之所以这样命名,是因为其成员不会自动通过逻辑或视觉关系连接到该集合的所有者)。我们的定义如下:

public class DisconnectedUIElementCollection 
    : UIElementCollection, INotifyCollectionChanged

正如要求的那样,我们从 UIElementCollection 派生我们的类。我们还决定实现 INotifyCollectionChanged 以使我们的集合可观察。毕竟,我们的所有者需要某种方式来监视集合中的更改。请注意,此更改通知机制对于基类 UIElementCollection 不是必需的,因为其所有者可以通过重写 OnVisualChildrenChanged() 来接收更改通知。

接下来,我们需要一个内部集合来存储我们的断开连接的子元素,所以我们只需定义以下私有成员:

private Collection<UIElement> _elements
    = new Collection<UIElement>();

请牢记这个 `_elements` 集合,因为我们在接下来的步骤中会经常引用它。

现在,是时候考虑我们的构造函数了……

我们知道必须调用基类 UIElementCollection 的构造函数,该构造函数需要两个参数:visualParentlogicalParent

visualParent 参数必须是一个有效的 UIElement。它不能为 `null`。在内部,框架假定这是集合的所有者。事实上,VisualCollection 类在内部将其命名为 `_owner`。(谢谢,Lutz Roeder……我仍然更喜欢您的工具,而不是单步执行源代码!)

logicalParent 参数可以为 `null`,也可以是任何 FrameworkElement。如果不为 `null`,则逻辑父元素通常与视觉父元素是同一个元素。

由于我们试图推卸我们的父责任,所以在调用基类构造函数时,我们将对框架耍个小把戏。(这是完全可以接受的,因为我在愚人节想出了这个主意!)

介绍 SurrogateVisualParent 类

回想一下,VisualCollection 会在添加成员时自动将它们分配给一个视觉父元素(其所有者)。我们不希望我们的面板成为该所有者,因此很明显,我们需要一个“代理”视觉父元素来处理面板的子元素。这个代理可以仅仅将子元素传递给我们,然后就完成了!

嗯……仔细想想……也许我们可以将代理父元素用于更多用途……如果我们开发代理,使其成为一个非常轻量级的 UIElement,我们可以将其用作一个“事件接收器”,以便确切地知道何时将子元素添加到 VisualCollection 或从中移除。这可以帮助我们解决一个最大的问题……即框架通过其对 UIElementCollection 的后门访问来移除子元素。

要获得所需的添加/移除通知,我们只需在代理类中重写 OnVisualChildrenChanged()。不错!

目前,我们只需将代理类定义如下,然后再回来实现 OnVisualChildrenChanged()

private class SurrogateVisualParent : UIElement
{
    internal void InitializeOwner(
        DisconnectedUIElementCollection owner)
    {
        _owner = owner;
    }
 
    protected override void OnVisualChildrenChanged(
        DependencyObject visualAdded, DependencyObject visualRemoved)
    {
        base.OnVisualChildrenChanged(visualAdded, visualRemoved);
    }
 
    private DisconnectedUIElementCollection _owner;
}

定义 DisconnectedUIElementCollection 的构造函数

既然我们有了 SurrogateVisualParent 类,我们需要为我们的自定义断开连接的 UI 元素集合公开一个公共构造函数。我们实际上将此构造函数定义为如下:

public DisconnectedUIElementCollection(UIElement owner)
    : this(owner, new SurrogateVisualParent()) {}

请注意,我们的构造函数将接受一个参数,该参数代表拥有断开连接集合的元素。最终这将是我们的自定义面板 ConceptualPanel。传递所有者引用稍后会解释。

另请注意,此构造函数创建 SurrogateVisualParent 类的实例,然后调用另一个构造函数。我们可以将另一个构造函数定义为私有成员:

private DisconnectedUIElementCollection(UIElement owner, 
    SurrogateVisualParent surrogateVisualParent) 
    : base(surrogateVisualParent, null)
{
    _ownerPanel = owner as Panel;
    _surrogateVisualParent = surrogateVisualParent;
    _surrogateVisualParent.InitializeOwner(this);
}

在这里,我们使用私有构造函数调用基类 UIElementCollection 的构造函数。回想一下,基类构造函数的第一参数指定了添加到内部 VisualCollection 的子元素的视觉父元素。正如预期的那样,我们提供了一个代理视觉父元素。然后,我们将第二个参数指定为 `null`,以便不为集合中的子元素建立逻辑父关系。

在私有构造函数的正文中,我们存储了集合所有者面板的引用。(如果所有者不是面板,则此成员将为 `null`,这实际上对我们来说非常完美。)我们还存储了对代理父元素的私有引用,然后初始化该父元素,使其拥有指向 DisconnectedUIElementCollection 的引用。

重写基类集合例程

我们的下一个任务是重写基类 UIElementCollection 的所有虚拟函数。

首先,我们需要确保任何试图访问断开连接的子元素的函数都重定向到我们的私有 `_elements` 集合。(请注意,我们尚未向该集合添加任何代码来填充它,但我们肯定会在完成之前完成。)

为了实现此重定向,我们只需实现许多如下所示的“重定向重写”

public override int Count
{
    get { return _elements.Count; }
}
 
public override int IndexOf(UIElement element)
{
    return _elements.IndexOf(element);
}

我不会在此包含所有此类函数,但您肯定明白我的意思。

上述方法处理了返回有关我们集合的信息的访问器。那么,关于修改我们集合的访问器呢?嗯,我们希望这些类型的访问器将所有修改委托给基类。(回想一下,我们将通过 SurrogateVisualParent 中的 OnVisualChildrenChanged() 事件接收器来处理这些修改。)

因此,就像我们为上面的访问器所做的那样,我们实现许多如下所示的“委托重写”

public override int Add(UIElement element)
{
    return base.Add(element);
}
 
public override void Insert(int index, UIElement element)
{
    base.Insert(index, element);
}

好吧,如果我们只是委托给基类,那我们为什么要重写方法呢?嗯,事实证明,我们需要在这些委托重写中的每一个中添加一些东西。

与项容器生成器良好协作

在我们对基类 UIElementCollection 的分析中,我们发现当集合的所有者是作为 ItemsControl 的布局宿主元素(也称为项宿主)的面板时,该集合的某些公共方法实际上变为“私有”。这实际上是一种优化,框架基本上将面板的 Children 集合的所有权分配给了 ItemsControl 的项容器生成器。

如果直接更改 UIElementCollection(当它处于项容器生成器的伪所有权下时),框架将引发异常!当然,回想一下,我们通过指定非 Panel 的代理父元素来欺骗了框架。这意味着我们对基类方法的调用实际上会成功,即使真正的概念父元素可能是面板。这似乎非常酷,但实际上我们不想要这个!最终,如果我们的面板是项宿主,这种额外的自由可能会导致我们的 Children 集合与我们 ItemsControlItems 集合不同步。

由于我们的最终目标是创建一个在所有场景下都能正常工作的概念面板,尤其是在面板用作 ItemsControl 的项宿主时,我们需要确保我们的 DisconnectedUIElementCollection 遵循与基类 UIElementCollection 相同的规则。因此,我们需要同样保护对所有直接修改集合的方法的访问,当所有者是项容器生成器时。

为了实现这种保护,我们实现了以下例程:

private void VerifyWriteAccess()
{
    // if the owner is not a panel, just return
    if (_ownerPanel == null) return;
 
    // check whether the owner is an items host for an ItemsControl
    if (_ownerPanel.IsItemsHost 
            && ItemsControl.GetItemsOwner(_ownerPanel) != null)
        throw new InvalidOperationException(
            "Disconnected children cannot be explicitly added to "
          + "this collection while the panel is serving as an "
          + "items host. However, visual children can be added "
          + "by simply calling the AddVisualChild method.");
}

请注意,我们在错误消息中添加了一个注意事项,其中提到了我们稍后在实现 ConceptualPanel 类时将添加的一个功能。我们现在不必担心。

现在,我们只需在所有委托重写开始时添加一个对 VerifyWriteAccess() 的调用,如下所示:

public override int Add(UIElement element)
{
    VerifyWriteAccess();
    return base.Add(element);
}

public override void Insert(int index, UIElement element)
{
    VerifyWriteAccess();
    base.Insert(index, element);
}

好的,到这个时候,我们已经实现了 DisconnectedUIElementCollection 中所有必需的重写,并且我们已经保护了对修改集合的方法的访问(当所有者面板是项宿主时)。

还有工作要做。我们尚未添加任何代码来确保当一个项添加到框架的内部 VisualCollection 时,我们也将其添加到我们的私有 `_elements` 集合中。此外,我们还没有做任何事情来确保添加的视觉元素是未父化的。

在代理父元素中监视视觉子元素

为了填充我们的 `_elements` 集合,我们需要监视代理父元素中视觉子元素的添加和删除。我们将在我们的临时事件接收器中收到这些事件。回想一下,此方法具有以下签名:

protected override void OnVisualChildrenChanged(
    DependencyObject visualAdded, DependencyObject visualRemoved)

让我们先处理 `visualAdded` 事件。当一个子元素被添加到代理父元素时,我们也需要将该子元素添加到我们的 `_elements` 集合中。这当然很容易。我们可以这样做:

if (visualAdded != null)
{
    _owner._elements.Add (visualAdded as UIElement);
}

当然,我们还需要确保添加的视觉元素不再有视觉父元素。我们可以简单地让代理父元素调用 RemoveVisualChild()。不幸的是,这最终会给我们带来麻烦,因为 VisualCollection 假定其所有成员都是提供的所有者(代理父元素)的视觉子元素。如果后续调用尝试从其集合中移除子元素,它肯定会引发异常。

实际上,解除子元素父级的唯一安全方法是将其完全从 VisualCollection 中移除,并仅在我们的 `_elements` 集合中维护引用。为此,我们可以获取子元素的索引,然后调用基类 UIElementCollectionRemoveAt() 方法。为了支持这一点,我们需要在 DisconnectedUIElementCollection 中添加私有的 `BaseIndexOf()` 和 `BaseRemoveAt()` 方法,以使 SurrogateVisualParent 类能够访问 UIElementCollection 的基类方法。

private int BaseIndexOf(UIElement element)
{
    return base.IndexOf(element);
}
 
private void BaseRemoveAt(int index)
{
    base.RemoveAt(index);
}

将这些成员设为私有是没问题的,因为 SurrogateVisualParent 类被定义为 DisconnectedUIElementCollection 中的私有类。

现在,当我们调用 OnVisualChildrenChanged() 中的 BaseRemoveAt() 时,它实际上会再次导致我们的代理父元素的视觉子元素发生变化……这次是移除。由于我们打算使用接收器来接收移除事件,因此我们希望忽略此特定的移除事件,因为是我们自己引起的。我们可以简单地向我们的类添加一个 `_internalUpdate` 标志来避免这种重入。我们更新后的接收器(在 SurrogateVisualParent 中)看起来像这样(回想一下 `_owner` 是对我们 DisconnectedUIElementCollection 的引用):

protected override void OnVisualChildrenChanged(
    DependencyObject visualAdded, DependencyObject visualRemoved)
{
    // avoid reentrancy during internal updates
    if (_internalUpdate) return;

 
    _internalUpdate = true;
    try
    {
        if (visualAdded != null)
        {
            UIElement element = visualAdded as UIElement;
            int index = _owner.BaseIndexOf (element);
            _owner.BaseRemoveAt(index);
            _owner._elements.Add(element);
        }
    }
    finally
    {
        _internalUpdate = false;
    }
} 

private bool _internalUpdate = false;

请注意,我们没有调用基类 OnVisualChildrenChanged() 方法,并且知道我们的祖先(UIElementVisual)在此重写中不执行任何操作。

好了,有人看到我们方法的巨大漏洞了吗?

我们假设我们可以使用上述方法作为移除操作的事件接收器,但我们刚刚移除了我们想要跟踪的元素。如果框架选择通过其上述的后门移除该元素,我们现在永远无法收到所需的移除事件。

处理后门移除

这时,我们需要更多关于框架在 UIElementCollection 方面的内部工作知识。即,可能移除元素的后门方法是什么?稍微(好吧,很多)反思一下就会发现,以下方法可能导致元素被移除:

internal void ClearInternal();
public virtual void RemoveRange(int index, int count);
internal void SetInternal(int index, UIElement item);

还有其他内部后门方法,但它们只处理添加元素,或者仅在 UI 虚拟化场景下调用(请参见本文末尾的已知限制部分)。

我们破解的关键实际上在于这三个内部方法:即,这些都是基于索引的操作。好吧,ClearInternal 方法并不一定表示基于索引的操作,但通过对 VisualCollectionClear 方法进行更深入的反思,可以看到内部集合只是按索引枚举,并且每个项都被断开了与其视觉父级的连接。

掌握了视觉元素是根据它们在集合中的索引被移除的知识,我们可以巧妙地解决如何跟踪视觉子元素从代理父元素中移除的问题……

秘密替换框架的 usual Coffee

我们已经使用了事件接收器从基类 UIElementCollection 中移除了新添加的子元素。这有效地解除子元素的父级。然后,我们将未父化的子元素添加到了我们的私有 `_elements` 集合中。现在,我们只需要秘密地在基类集合中插入其他元素来替换旧子元素的位置。这将导致该元素被添加为我们代理父元素的视觉子元素。然后稍后,当项容器生成器决定应在特定索引处移除一个子元素时,将有一个实际的元素可以被移除。

其他元素应该是什么?只要它是一个 UIElement,它其实并不重要。如果有一个简单的方法可以将替换元素与它所替换的元素关联起来,那就太好了,所以我们应该定义一个简单的 UIElement,其中有一个属性指向它所代表的“兄弟”。为了方便起见,我们可以将替换元素称为 DegenerateSibling 并如下定义:

private class DegenerateSibling : UIElement
{
    public DegenerateSibling(UIElement element)
    {
        _element = element;
    }

    public UIElement Element
    {
        get { return _element; }
    }
 

    private UIElement _element;
}

这也 DisconnectedUIElementCollection 中的一个私有类。

现在,我们可以完成 `visualAdded` 事件的逻辑了。

if (visualAdded != null:
{
    UIElement element = visualAdded as UIElement;
    DegenerateSibling sibling = new DegenerateSibling(element);
    int index = _owner.BaseIndexOf(element);
    _owner.BaseRemoveAt (index);
    _owner.BaseInsert(index, sibling);
    _owner._degenerateSiblings[element] = sibling;
    _owner._elements.Insert(index, element);
    _owner.RaiseCollectionChanged(
        NotifyCollectionChangedAction.Add, element, index);
}

我们现在正在创建退化兄弟元素,并使用 `BaseInsert()` 方法将其插入到基集合中与刚刚移除的实际子元素相同的槽位。我们还使用 `Insert()` 方法将实际子元素插入到我们的私有 `_elements` 集合中,以使集合与退化兄弟元素的集合完全同步。

此外,我们还维护一个我们创建的所有退化兄弟元素的字典。这被我们的 DisconnectedUIElementCollection 类用于按引用移除元素(而不是按索引移除)。

最后,每当添加元素时,我们都会引发更改通知。如前所述,这将允许 DisconnectedUIElementCollection 的所有者响应集合中的更改。

现在,我们只需要添加一些代码来处理 `visualRemoved` 事件。下面是非常直接的实现:

if (visualRemoved != null)
{
    DegenerateSibling sibling = visualRemoved as DegenerateSibling;
    int index = _owner._elements.IndexOf(sibling.Element);
    _owner._elements.RemoveAt(index);
    _owner.RaiseCollectionChanged(
    NotifyCollectionChangedAction.Remove, sibling.Element, index);
        _owner._degenerateSiblings.Remove (sibling.Element);
}

当移除退化兄弟元素时,我们只需找到它所代表的实际子元素,并同样将其从我们的私有 `_elements` 集合中移除。当然,我们还需要提供更改通知,并清除字典中退化元素的引用。

这就差不多了!我们现在有了一个自定义的 UIElementCollection 派生类,它可以用于存储面板的子元素,而无需自动将它们设为面板的视觉(或逻辑)子元素。

创建概念子元素面板

现在,我们只需要创建一个使用我们自定义集合来存储其未父化子元素的面板。这是最简单不过的部分!以下是类定义的关键部分:

public abstract class ConceptualPanel : Panel
{
    protected override sealed UIElementCollection 
        CreateUIElementCollection(FrameworkElement logicalParent)
    {
        DisconnectedUIElementCollection children 
            = new DisconnectedUIElementCollection(this);
        children.CollectionChanged 
            += new NotifyCollectionChangedEventHandler
                (OnChildrenCollectionChanged);
        return children;
    }
 
    protected virtual void OnChildAdded(UIElement child)
    {
    }
 
    protected virtual void OnChildRemoved(UIElement child)
    {
    }
 
    private void OnChildrenCollectionChanged(object sender, 
        NotifyCollectionChangedEventArgs e)
    {
        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add:
                OnChildAdded (e.NewItems[0] as UIElement);
                break;
 
            case NotifyCollectionChangedAction.Remove:
                OnChildRemoved(e.OldItems[0] as UIElement);
                break;
        }
    }
}

此类仅重写 CreateUIElementCollection() 以创建 DisconnectedUIElementCollection 的实例。它设置了一个处理程序来监视集合中的更改。当添加或移除子元素时,它会调用一个代表该操作的虚拟方法。派生自 ConceptualPanel 的类可以简单地重写 OnChildAdded()OnChildRemoved() 来响应子元素的进出。

在实际的 ConceptualPanel 实现中,您会注意到我添加了以下代码:

public ConceptualPanel()
{
    Loaded += OnLoaded;
}
 
void OnLoaded(object sender, RoutedEventArgs e)
{
    Loaded -= OnLoaded;
    (Children as DisconnectedUIElementCollection).Initialize();
}

这只是确保在面板创建期间最早可能的时间点创建断开连接的子集合。

我们还需要修复面板的视觉子元素概念。基类 Panel 假定 Children 集合中的任何内容都是视觉子元素。现在,由于我们的面板使用了 DisconnectedUIElementCollection,这不再是真的。现在,这些元素只是概念性子元素。

因此,我们必须重写 VisualChildrenCount 属性和 GetVisualChild() 方法。为了支持 ConceptualPanel 中的直接视觉子元素,我们可以添加一个私有的 `_visualChildren` 集合,并使用我们的重写来返回该集合的成员。以下代码通过监视面板中的 OnVisualChildrenChanged() 来实现此目的:

protected override int VisualChildrenCount
{
    get { return _visualChildren.Count; }
}
 
protected override Visual GetVisualChild(int index)
{
    if (index < 0 || index >= _visualChildren.Count) 
        throw new ArgumentOutOfRangeException();
    return _visualChildren[index];
}
 
protected override void OnVisualChildrenChanged(
    DependencyObject visualAdded, DependencyObject visualRemoved)
{
    if (visualAdded is Visual)
    {
        _visualChildren.Add(visualAdded as Visual);
    }
 
    if (visualRemoved is Visual)
    {
        _visualChildren.Remove(visualRemoved as Visual);
    }
 
    base.OnVisualChildrenChanged(visualAdded, visualRemoved);
}
 
private readonly List<Visual> _visualChildren = new List<Visual>();

ConceptualPanel 实现到此结束。

创建逻辑子元素面板

很多时候,您可能希望面板中的子元素是面板的逻辑子元素,而不是视觉子元素。这将允许像资源解析和属性继承(通过逻辑树工作)这样的功能与面板的子元素无缝协同工作。因此,我们可以创建一个 LogicalPanel 类,它派生自 ConceptualPanel,如下所示:

public abstract class LogicalPanel : ConceptualPanel
{
    protected sealed override void OnChildAdded(UIElement child)
    {
        if (LogicalTreeHelper.GetParent(child) == null)
        {
            AddLogicalChild(child);
        }
        OnLogicalChildrenChanged(child, null);
    }
 
    protected sealed override void OnChildRemoved(UIElement child)
    {
        if (LogicalTreeHelper.GetParent(child) == this)
        {
            RemoveLogicalChild(child);
        }
        OnLogicalChildrenChanged(null, child);
    }
 
    protected virtual void OnLogicalChildrenChanged(
        UIElement childAdded, UIElement childRemoved)
    {
    } 
}

此类提供了一个名为 OnLogicalChildrenChanged() 的虚拟函数,该函数有意类似于 OnVisualChildrenChanged() 函数。派生面板可以通过重写此方法来响应逻辑子元素的添加或移除。

可能值得一提的是,此方法仅宣布属于 Children 集合的逻辑子元素的到达或离开。这意味着它仅适用于 UIElement。添加到面板的任何其他逻辑子元素都不会导致 OnLogicalChildrenChanged() 方法的执行。

就是这样!现在我们有了一个纯粹的逻辑面板。

ConceptualPanel 架构的已知限制

了解自己的局限性总是好的。以下是这些类当前实现的一些已知限制。我相信我还没有想到所有情况,因此如果/何时引起我注意其他限制,我将更新此部分。

1. ConceptualPanel 的视觉子元素的 Panel.ZIndex 未被尊重

这也不是什么大问题,因为 ConceptualPanel 默认情况下甚至没有视觉子元素,但至少值得一提。如果您通过 AddVisualChild()ConceptualPanel 添加视觉子元素,然后在这些子元素上设置 Panel.ZIndex 属性,它将无效。与非面板元素一样,在枚举期间返回视觉子元素的顺序完全取决于它们通过 AddVisualChild() 添加的顺序。当然可以添加 z 排序,但由于 ConceptualPanel 可能甚至没有视觉子元素,我认为这不值得额外的努力。

2. ConceptualPanel 不提供 UI 虚拟化

ConceptualPanel 派生自 Panel,因此当它充当项宿主时,没有内置的 UI 虚拟化。所有生成的项容器都将属于 Children 集合。因此,您将为每个容器付出内存成本,以及实例化容器及其视觉元素的 CPU 周期处理成本。但是,除非您明确将概念子元素添加到视觉树,否则您不会为它们支付渲染成本。

理论上,您可以编写一个派生自 VirtualizingPanel 并使用 DisconnectedUIElementCollection 来保存其子元素的 ConceptualVirtualizingPanel 类。然后,您将拥有一个具有断开连接的子元素的虚拟化面板。但是,虚拟化面板倾向于回收项容器并将容器在内部 VisualCollection 中移动。UIElementCollection 暴露的另外几个后门方法(MoveVisualChild()RemoveNoVerify())在这些场景下会起作用。这些操作基于元素引用而不是元素索引来修改集合。这可能会破坏 DisconnectedUIElementCollection 方法。

显然,如果需要概念性虚拟化面板,还需要进行一些额外的调查。我将其留给更有野心的黑客作为练习。

3. ConceptualPanel 的子元素仍然可能有一个逻辑父元素

此架构仅确保 ConceptualPanel 本身不会成为其子元素的逻辑父元素。仍然很有可能将具有不同逻辑父元素的子元素添加到 Children 集合中。最常发生这种情况的场景是 ConceptualPanel 是项宿主,并且一个符合条件的项容器的子元素被添加到控制 ItemsControlItems 集合中。

添加到 ItemsControlItems 集合中的任何对象都将成为该 ItemsControl 的逻辑子元素。这是 ItemsControl 内容模型的一部分,如“D”代表 DataTemplate中所述。这意味着,如果一个项容器直接添加到 Items 集合中(而不是由 ItemsControlItemContainerGenerator 生成),当它添加到 ConceptualPanel 时,它将已经是 ItemsControl 的逻辑子元素。不可能从面板内部切断这种关系(除非您恢复调用内部框架方法,即使这样我也不推荐那种程度的黑客攻击)。

有关 ItemsControl 类及其各自项容器的列表,请参见“I”代表项容器末尾的表格。

眼见为实

在 Josh 的许可下,我已更新 WPF Disciples Blogroll 应用程序中的 Panel3D 类,使其派生自 LogicalPanel

您可以通过本文开头提供的链接下载代码。

此软件包包含我的 DisconnectedUIElementCollectionConceptualPanelLogicalPanel 类的完整源代码。请注意,除了我的更改之外,此示例还包含 Josh 自己添加的几项改进。(显然,他认为这是一个进行中的项目,我知道我们都期待看到它的发展方向!)

除了将基类更改为 LogicalPanel 之外,我还从 Adorner 层中移除了 Viewport3D 元素,并将其添加为 Panel3D 元素的直接视觉子元素。视口现在是面板唯一的视觉子元素。由于派生自 LogicalPanel,因此面板 Children 集合中的所有元素仅仅是面板的逻辑子元素。这使得它们可以在 3D 场景中用作各自 Viewport2DVisual3D 元素的直接视觉子元素。

我希望其他人会发现这种“概念子元素”的新概念和我一样酷!

© . All rights reserved.