如何为新面板重用现有布局代码 Class?

How to Reuse Existing Layouting Code for new Panel Class?

tl;dr: 我想为自定义 WPF 面板 class 重用 pre-defined WPF panel 的现有布局逻辑。这个问题包含四种不同的尝试来解决这个问题,每一种都有不同的缺点,因此有不同的失败点。另外,可以在下面找到一个小的测试用例。

问题是:我该如何正确实现这个目标


我正在尝试编写自定义 WPF panel。对于此面板 class,我想坚持推荐的开发实践并保持干净的 API 和内部实现。具体来说,这意味着:

至于目前,我将继续使用现有的布局,我想 re-use 另一个面板的布局代码(而不是像建议的那样重新编写布局代码,例如 here). For the sake of an example, I will explain based on DockPanel,尽管我想知道如何基于任何类型的 Panel.

通常执行此操作

为了重用布局逻辑,我打算在我的面板中添加一个 DockPanel 作为可视化 child,然后它将保存和布局我的逻辑 children面板。

关于如何解决这个问题,我尝试了三种不同的想法,并且在评论中提出了另一种想法,但到目前为止,每种想法都在不同的地方失败了:


1) 在自定义面板的控件模板中引入内部布局面板

这似乎是最优雅的解决方案 - 这样,自定义面板的控制面板可以具有自定义面板的 ItemsControl whose ItemsPanel property is uses a DockPanel, and whose ItemsSource property is bound to the Children property

遗憾的是,Panel does not inherit from Control and hence does not have a Template property 也不支持控件模板。

另一方面,Children property 是由 Panel 引入的,因此在 Control 中不存在,我觉得打破预期可能被认为是 hacky继承层次结构并创建一个实际上是 Control 但不是 Panel.

的面板

2) 提供我面板的 children 列表,它只是内部面板 children 列表的包装器

这样的 class 看起来如下图所示。我已经 subclassed UIElementCollection in my panel class and returned it from an overridden version of the CreateUIElementCollection method. (I have only copied the methods that are actually invoked here; I have implemented the others to throw a NotImplementedException,所以我确定没有调用其他可覆盖成员。)

using System;
using System.Windows;
using System.Windows.Controls;

namespace WrappedPanelTest
{
    public class TestPanel1 : Panel
    {
        private sealed class ChildCollection : UIElementCollection
        {
            public ChildCollection(TestPanel1 owner) : base(owner, owner)
            {
                if (owner == null) {
                    throw new ArgumentNullException("owner");
                }

                this.owner = owner;
            }

            private readonly TestPanel1 owner;

            public override int Add(System.Windows.UIElement element)
            {
                return this.owner.innerPanel.Children.Add(element);
            }

            public override int Count {
                get {
                    return owner.innerPanel.Children.Count;
                }
            }

            public override System.Windows.UIElement this[int index] {
                get {
                    return owner.innerPanel.Children[index];
                }
                set {
                    throw new NotImplementedException();
                }
            }
        }

        public TestPanel1()
        {
            this.AddVisualChild(innerPanel);
        }

        private readonly DockPanel innerPanel = new DockPanel();

        protected override UIElementCollection CreateUIElementCollection(System.Windows.FrameworkElement logicalParent)
        {
            return new ChildCollection(this);
        }

        protected override int VisualChildrenCount {
            get {
                return 1;
            }
        }

        protected override System.Windows.Media.Visual GetVisualChild(int index)
        {
            if (index == 0) {
                return innerPanel;
            } else {
                throw new ArgumentOutOfRangeException();
            }
        }

        protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize)
        {
            innerPanel.Measure(availableSize);
            return innerPanel.DesiredSize;
        }

        protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize)
        {
            innerPanel.Arrange(new Rect(new Point(0, 0), finalSize));
            return finalSize;
        }
    }
}

几乎 正确; DockPanel 布局按预期重用。唯一的问题是绑定无法按名称在面板中找到控件(使用 ElementName property)。

我已经尝试从 LogicalChildren property 返回内部 children,但这并没有改变任何东西:

protected override System.Collections.IEnumerator LogicalChildren {
    get {
        return innerPanel.Children.GetEnumerator();
    }
}

在每个 child 的 by user Arie, the NameScope class was pointed out to have a crucial role in this: The names of the child controls do not get registered in the relevant NameScope for some reason. This might be partially fixed by invoking RegisterName 中,但需要检索正确的 NameScope 实例。另外,我不确定 child 的名称更改时的行为是否与其他面板中的相同。

相反,设置内部面板的 NameScope 似乎是可行的方法。我尝试使用直接绑定(在 TestPanel1 构造函数中):

        BindingOperations.SetBinding(innerPanel,
                                     NameScope.NameScopeProperty,
                                     new Binding("(NameScope.NameScope)") {
                                        Source = this
                                     });

不幸的是,这只是将内部面板的 NameScope 设置为 null。据我所知,通过 parent window 的 Snoop, the actual NameScope instance is only stored in the NameScope attached property 或由控件模板(或可能由其他关键节点)定义的封闭可视化树的根?),无论什么类型。当然,一个控件实例在其生命周期中可能会在控件树的不同位置添加和删除,因此相关的 NameScope 可能会不时更改。这再次需要绑定。

这是我再次陷入困境的地方,因为不幸的是,无法定义 RelativeSource for the binding based on an arbitrary condition such as *the first encountered node that has a non-null value assigned to the NameScope attached property

除非 this other question about how to react to updates in the surrounding visual tree 产生有用的响应,是否有更好的方法来检索 and/or 绑定到当前与任何给定框架元素相关的 NameScope


3) 使用内部面板,其 children 列表与外部面板的实例完全相同

不是将 child 列表保留在内部面板中并将调用转发到外部面板的 child 列表,而是 kind-of 相反。在这里,只使用了外部面板的 child 列表,而内部面板从不创建自己的列表,而只是使用相同的实例:

using System;
using System.Windows;
using System.Windows.Controls;

namespace WrappedPanelTest
{
    public class TestPanel2 : Panel
    {
        private sealed class InnerPanel : DockPanel
        {
            public InnerPanel(TestPanel2 owner)
            {
                if (owner == null) {
                    throw new ArgumentNullException("owner");
                }

                this.owner = owner;
            }

            private readonly TestPanel2 owner;

            protected override UIElementCollection CreateUIElementCollection(FrameworkElement logicalParent)
            {
                return owner.Children;
            }
        }

        public TestPanel2()
        {
            this.innerPanel = new InnerPanel(this);
            this.AddVisualChild(innerPanel);
        }

        private readonly InnerPanel innerPanel;

        protected override int VisualChildrenCount {
            get {
                return 1;
            }
        }

        protected override System.Windows.Media.Visual GetVisualChild(int index)
        {
            if (index == 0) {
                return innerPanel;
            } else {
                throw new ArgumentOutOfRangeException();
            }
        }

        protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize)
        {
            innerPanel.Measure(availableSize);
            return innerPanel.DesiredSize;
        }

        protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize)
        {
            innerPanel.Arrange(new Rect(new Point(0, 0), finalSize));
            return finalSize;
        }
    }
}

在这里,可以按名称布局和绑定到控件。但是,控件不可单击。

我怀疑我必须以某种方式将呼叫转发给 HitTestCore(GeometryHitTestParameters) and to HitTestCore(PointHitTestParameters) to the inner panel. However, in the inner panel, I can only access InputHitTest, so I am neither sure how to safely process the raw HitTestResult instance without losing or ignoring any of the information that the original implementation would have respected, nor how to process the GeometryHitTestParameters, as InputHitTest only accepts a simple Point

此外,控件也不可聚焦,例如按 Ta。我不知道如何解决这个问题。

此外,我对这种方式有点谨慎,因为我不确定内部面板和 children 的原始列表之间有哪些内部链接,我通过替换 [=204] 的列表来破坏=]ren 自定义 object.


4) 直接从面板继承class

用户Clemens suggests to directly have my class inherit from DockPanel。但是,这不是一个好主意的原因有两个:


作为观察所描述问题的测试用例,在 XAML 中添加正在测试的自定义面板的实例,并在该元素中添加以下内容:

<TextBox Name="tb1" DockPanel.Dock="Right"/>
<TextBlock Text="{Binding Text, ElementName=tb1}" DockPanel.Dock="Left"/>

文本块应位于文本框的左侧,并且应显示文本框中当前写入的内容。

我希望文本框是可点击的,并且输出视图不会显示任何绑定错误(因此,绑定也应该有效)。


因此,我的问题是:

如果您使用第二种方法(提供我的面板的子列表,它只是内部面板的子列表的包装)的唯一问题是无法按名称绑定到内部面板的控件,那么解决方案将是:

    public DependencyObject this[string childName]
    {
        get
        {
            return innerPanel.FindChild<DependencyObject>(childName);
        }
    }

然后,示例绑定:

"{Binding ElementName=panelOwner, Path=[innerPanelButtonName].Content}"

FindChild方法的实现:


编辑:

如果你想要 "usual" binding by ElementName to work, you'll have to register the names of controls that are children of the innerPanel in the appropriate NameScope:

var ns = NameScope.GetNameScope(Application.Current.MainWindow);

foreach (FrameworkElement child in innerPanel.Children)
{
    ns.RegisterName(child.Name, child);
}

现在绑定 {Binding ElementName=innerPanelButtonName, Path=Content} 将在运行时工作。

问题是可靠地找到根 UI 元素以获得 NameScope(这里:Application.Current.MainWindow - 在设计时不起作用)


OP 编辑​​:这个答案让我走上了正确的轨道,因为它提到了 NameScope class

我的最终解决方案基于 TestPanel1 并使用 INameScope interface. Each of its methods walks up the logical tree, starting at the outer panel, to find the nearest parent element whose NameScope property 的自定义实现,而不是 null:

  • RegisterNameUnregisterName 将它们的调用转发给找到的 INameScope 对象的相应方法,否则抛出异常。
  • FindName 将其调用转发给找到的 INameScope 对象的 FindName,否则(如果未找到此类对象)returns null.

INameScope 实现的一个实例被设置为内部面板的 NameScope

我不确定你是否可以完全隐藏面板的内部结构——据我所知,WPF 在构建 visual/logical 树时没有使用 "backdoor" 访问权限,所以一旦你隐藏来自用户的东西,你也从 WPF 中隐藏它。我要做的是使结构 "read-only" (通过保持结构可访问,您无需担心绑定机制)。为此,我建议从 UIElementCollection 派生并覆盖所有用于更改集合状态的方法,并将其用作面板的子集合。至于 "tricking" XAML 将子项直接添加到内部面板中,您可以简单地将 ContentPropertyAttribute 与 属性 一起使用,以暴露内部面板的子项集合。这是此类面板的一个工作示例(至少对于您的测试用例而言):

[ContentProperty("Items")]
public class CustomPanel : Panel
{
    public CustomPanel()
    {
        //the Children property seems to be lazy-loaded so we need to
        //call the getter to invoke CreateUIElementCollection
        Children.ToString();
    }

    private readonly Panel InnerPanel = new DockPanel();

    public UIElementCollection Items { get { return InnerPanel.Children; } }

    protected override Size ArrangeOverride(Size finalSize)
    {
        InnerPanel.Arrange(new Rect(new Point(0, 0), finalSize));
        return finalSize;
    }

    protected override UIElementCollection CreateUIElementCollection(FrameworkElement logicalParent)
    {
        return new ChildCollection(this);
    }

    protected override Size MeasureOverride(Size availableSize)
    {
        InnerPanel.Measure(availableSize);
        return InnerPanel.DesiredSize;
    }

    private sealed class ChildCollection : UIElementCollection
    {
        public ChildCollection(CustomPanel owner)
            : base(owner, owner)
        {
            //call the base method (not the override) to add the inner panel
            base.Add(owner.InnerPanel);
        }

        public override int Add(UIElement element) { throw new NotSupportedException(); }

        public override void Clear() { throw new NotSupportedException(); }

        public override void Insert(int index, UIElement element) { throw new NotSupportedException(); }

        public override void Remove(UIElement element) { throw new NotSupportedException(); }

        public override void RemoveAt(int index) { throw new NotSupportedException(); }

        public override void RemoveRange(int index, int count) { throw new NotSupportedException(); }

        public override UIElement this[int index]
        {
            get { return base[index]; }
            set { throw new NotSupportedException(); }
        }
    }
}

或者,您可以跳过 ContentPropertyAttribute 并使用 public new UIElementCollection Children { get { return InnerPanel.Children; } } 公开内部面板的子集合 - 这也可行,因为 ContentPropertyAttribute("Children") 是从 Panel 继承的。

备注

为了防止使用隐式样式篡改内部面板,您可能需要使用 new DockPanel { Style = null }.

初始化内部面板