O. R. Mapper
O. R. Mapper

Reputation: 20760

How to Reuse Existing Layouting Code for new Panel Class?

tl;dr: I want to reuse the existing layouting logic of a pre-defined WPF panel for a custom WPF panel class. This question contains four different attempts to solve this, each with different downsides and thus a different point of failure. Also, a small test case can be found further down.

The question is: How do I properly achieve this goal of


I am trying to write a custom WPF panel. For this panel class, I would like to stick to recommended development practices and maintain a clean API and internal implementation. Concretely, that means:

As for the time being, I am going to closely stick with an existing layout, I would like to re-use another panel's layouting code (rather than writing the layouting code again, as suggested e.g. here). For the sake of an example, I will explain based on DockPanel, though I would like to know how to do this generally, based on any kind of Panel.

To reuse the layouting logic, I am intending to add a DockPanel as a visual child in my panel, which will then hold and layout the logical children of my panel.

I have tried three different ideas on how to solve this, and another one was suggested in a comment, but each of them so far fails at a different point:


1) Introduce the inner layout panel in a control template for the custom panel

This seems like the most elegant solution - this way, a control panel for the custom panel could feature an ItemsControl whose ItemsPanel property is uses a DockPanel, and whose ItemsSource property is bound to the Children property of the custom panel.

Unfortunately, Panel does not inherit from Control and hence does not have a Template property, nor feature support for control templates.

On the other hand, the Children property is introduced by Panel, and hence not present in Control, and I feel it could be considered hacky to break out of the intended inheritance hierarchy and create a panel that is actually a Control, but not a Panel.


2) Provide a children list of my panel that is merely a wrapper around the children list of the inner panel

Such a class looks as depicted below. I have 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, so I am certain that no other overrideable members were invoked.)

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;
        }
    }
}

This works almost correctly; the DockPanel layout is reused as expected. The only issue is that bindings do not find controls in the panel by name (with the ElementName property).

I have tried returned the inner children from the LogicalChildren property, but this did not change anything:

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

In an answer 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 for each child, but one would need to retrieve the correct NameScope instance. Also, I am not sure whether the behaviour when, for instance, the name of a child changes would be the same as in other panels.

Instead, setting the NameScope of the inner panel seems to be the way to go. I tried this with a straightforward binding (in the TestPanel1 constructor):

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

Unfortunately, this just sets the NameScope of the inner panel to null. As far as I could find out by means of Snoop, the actual NameScope instance is only stored in the NameScope attached property of either the parent window, or the root of the enclosing visual tree defined by a control template (or possibly by some other key node?), no matter what type. Of course, a control instance may be added and removed at different positions in a control tree during its lifetime, so the relevant NameScope might change from time to time. This, again, calls for a binding.

This is where I am stuck again, because unfortunately, one cannot define a 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.

Unless this other question about how to react to updates in the surrounding visual tree yields a useful response, is there a better way to retrieve and/or bind to the NameScope currently relevant for any given framework element?


3) Use an inner panel whose children list is simply the same instance as that of the outer panel

Rather than keeping the child list in the inner panel and forwarding calls to the outer panel's child list, this works kind-of the other way round. Here, only the outer panel's child list is used, while the inner panel never creates one of its own, but simply uses the same instance:

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;
        }
    }
}

Here, layouting and binding to controls by name works. However, the controls are not clickable.

I suspect I have to somehow forward calls to 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.

Moreover, the controls are also not focusable, e.g. by pressing Tab. I do not know how to fix this.

Besides, I am slightly wary of going this way, as I am not sure what internal links between the inner panel and the original list of children I am breaking by replacing that list of children with a custom object.


4) Inherit directly from panel class

User Clemens suggests to directly have my class inherit from DockPanel. However, there are two reasons why that is not a good idea:


As a test case for observing the described issues, add an instance of the custom panel being tested in XAML, and within that element, add the following:

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

The text block should be left of the text box, and it should show whatever is currently written in the text box.

I would expect the text box to be clickable, and the output view not to display any binding errors (so, the binding should work, as well).


Thus, my question is:

Upvotes: 12

Views: 1078

Answers (2)

Arie
Arie

Reputation: 5373

If your only problem with your second approach (Provide a children list of my panel that is merely a wrapper around the children list of the inner panel) is the lack of ability to bind to the inner panel's controls by name, then the soluton would be:

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

and then, the example binding:

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

the implementation of FindChild method: https://stackoverflow.com/a/1759923/891715


EDIT:

If you want the "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);
}

Now the binding {Binding ElementName=innerPanelButtonName, Path=Content} will work at runtime.

The problem with this is reliably finding the root UI element to get the NameScope (here: Application.Current.MainWindow - won't work in design time)


EDIT by OP: This answer brought me on the right track, as it mentioned the NameScope class.

My final solution is based on TestPanel1 and uses a custom implementation of the 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 is not null:

  • RegisterName and UnregisterName forward their invocation to the respective methods of that found INameScope object and otherwise throw an exception.
  • FindName forwards its invocation to FindName of the found INameScope object and otherwise (if no such object was found) returns null.

An instance of that INameScope implementation is set as the NameScope of the inner panel.

Upvotes: 2

Grx70
Grx70

Reputation: 10349

I'm not sure if you can completely obscure the inner structure of your panel - for all I know no "backdoor" access is used by WPF upon building visual/logical tree, so once you hide something from the user, you also hide it from WPF. What I'd go with would be to make the structure "read-only" (by keeping the structure accessible you don't need to worry about the binding mechanisms). To do that I suggest to derive from UIElementCollection and override all methods used to change the state of the collection, and use it as your panel's children collection. As for "tricking" XAML into adding the children directly into the inner panel you could simply use the ContentPropertyAttribute together with a property exposing the inner panel's children collection. Here's a working (at least for your test case) example of such a panel:

[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(); }
        }
    }
}

Alternatively, you could skip the ContentPropertyAttribute and expose the inner panel's children collection using public new UIElementCollection Children { get { return InnerPanel.Children; } } - this would also work since ContentPropertyAttribute("Children") is inherited from Panel.

REMARK

In order to prevent tampering with the inner panel using implicit styles you might want to initialize the inner panel with new DockPanel { Style = null }.

Upvotes: 2

Related Questions