12

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

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

  • 在定义我的自定义面板时
  • 内部重用另一个面板的布局逻辑,没有
  • 遇到我试图解决这个问题时描述的问题?

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

  • 我想避免复制和粘贴代码;如果代码的几个部分具有相同的功能,则该代码应该只存在一次并且可以重复使用。
  • 我想应用适当的封装,让外部用户只访问可以安全使用的成员(不破坏任何内部逻辑,或不泄露任何内部实现特定信息)。

就目前而言,我将密切关注现有布局,我想重新使用另一个面板的布局代码(而不是再次编写布局代码,如此处所建议的那样。举个例子,我将DockPanel根据Panel.

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

我已经尝试了三种不同的想法来解决这个问题,并且在评论中提出了另一种想法,但到目前为止,它们中的每一个都在不同的点上失败了:


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

这似乎是最优雅的解决方案 - 这样,自定义面板的控制面板可以具有ItemsControlItemsPanel属性使用 a DockPanel,并且其ItemsSource属性绑定到自定义面板的Children属性

不幸的是,Panel它没有继承自Control,因此没有Template属性,也没有对控件模板的功能支持。

另一方面,该Children属性是由 引入的Panel,因此不存在于 中,我觉得打破预期的继承层次并创建一个实际上是 a而不是 aControl的面板可能被认为是 hacky 。ControlPanel


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

这样的类如下所示。我UIElementCollection在面板类中进行了子类化,并从CreateUIElementCollection方法的覆盖版本返回它。(我只复制了这里实际调用的方法;我已经实现了其他方法来抛出 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属性)找到控件。

我试过从LogicalChildren属性返回内部孩子,但这并没有改变任何东西:

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

在用户Arie的回答中,该类被指出在这方面起着至关重要的作用:子控件的名称由于某种原因没有在相关中注册。这可以通过为每个孩子调用来部分解决,但需要检索正确的实例。此外,我不确定当孩子的名字发生变化时的行为是否与其他面板中的行为相同。NameScopeNameScopeRegisterNameNameScope

相反,设置NameScope内部面板的 似乎是要走的路。我尝试了一个简单的绑定(在TestPanel1构造函数中):

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

不幸的是,这只是将NameScope内部面板的 设置为null。据我通过Snoop发现,实际NameScope实例仅存储在父窗口的NameScope附加属性中,或者由控件模板定义的封闭可视树的根(或可能由其他关键节点? ),不管是什么类型。当然,一个控件实例在其生命周期中可能会在控件树的不同位置被添加和移除,因此相关的NameScope可能会不时发生变化。这再次要求绑定。

这就是我再次陷入困境的地方,因为不幸的是,无法RelativeSource根据任意条件为绑定定义 a ,例如 *the first遇到的节点具有null分配给NameScope附加属性的非值。

除非关于如何对周围可视树中的更新做出反应的其他问题产生有用的响应,否则是否有更好的方法来检索和/或绑定到NameScope当前与任何给定框架元素相关的元素?


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

与其将子列表保留在内部面板中并将调用转发到外部面板的子列表,不如说是反过来。在这里,只使用了外面板的子列表,而内面板从不创建自己的子列表,而只是使用相同的实例:

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)内部HitTestCore(PointHitTestParameters)面板。但是,在内部面板中,我只能访问InputHitTest,因此我不确定如何安全地处理原始HitTestResult实例而不丢失或忽略原始实现会尊重的任何信息,也不知道如何处理GeometryHitTestParameters,因为InputHitTest只接受简单Point

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

此外,我对这种方式有点谨慎,因为我不确定内部面板和原始子列表之间的内部链接是什么,我通过用自定义对象替换该子列表来破坏该列表。


4)直接从面板类继承

用户Clemens建议直接让我的类继承自DockPanel. 但是,这不是一个好主意有两个原因:

  • 我的面板的当前版本将依赖于DockPanel. 然而,在未来的某个时候,这可能已经不够了,有人确实必须在我的面板中编写自定义布局逻辑。在这种情况下,用自定义布局代码替换内部DockPanel是微不足道的,但从DockPanel我的面板的继承层次结构中删除将意味着重大更改。
  • 如果我的面板继承自DockPanel,则面板的用户可能会破坏其布局代码DockPanel,尤其是LastChildFill. 虽然它只是那个属性,但我想使用一种适用于所有Panel子类型的方法。例如,派生自的自定义面板Grid将公开ColumnDefinitionsRowDefinitions属性,通过该属性,任何自动生成的布局都可以通过自定义面板的公共接口完全销毁。

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

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

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

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


因此,我的问题是:

  • 我的任何一项尝试都可以解决以导致完全正确的解决方案吗?还是有一种完全其他的方式比我试图做的事情更可取?
4

2 回答 2

2

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

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

然后,示例绑定:

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

FindChild 方法的实现: https ://stackoverflow.com/a/1759923/891715


编辑:

如果您希望ElementName 的“通常”绑定起作用,则必须在适当的NameScope中注册作为 innerPanel 子级的控件的名称

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 编辑​​:这个答案让我走上了正确的轨道,因为它提到了NameScopeclass

我的最终解决方案是基于TestPanel1并使用INameScope接口的自定义实现。它的每个方法都从外部面板开始沿着逻辑树向上走,以找到NameScope属性不是的最近的父元素null

  • RegisterName并将UnregisterName它们的调用转发给找到的INameScope对象的相应方法,否则抛出异常。
  • FindName将它的调用转发给FindName找到的INameScope对象,否则(如果没有找到这样的对象)返回null

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

于 2015-07-01T07:47:37.137 回答
2

我不确定您是否可以完全掩盖面板的内部结构-据我所知,WPF 在构建视觉/逻辑树时没有使用“后门”访问,因此一旦您向用户隐藏某些内容,您也将其隐藏来自 WPF。我要做的是使结构“只读”(通过保持结构可访问,您无需担心绑定机制)。为此,我建议派生UIElementCollection并覆盖所有用于更改集合状态的方法,并将其用作面板的子集合。至于“欺骗”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 }.

于 2015-07-06T16:56:36.063 回答