Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

  • defining my custom panel while
  • internally reusing the layouting logic of another panel, without
  • running into the problems described in my attempts to solve this?

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:

  • I would like to avoid copy and pasting of code; if several portions of code have the same function, the code should exist only once and be reused.
  • I would like to apply proper encapsulation and let outside users access only such members that can safely be used (without breaking any of the internal logic, or without giving away any internal implementation-specific information).

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:

  • The current version of my panel will rely on the layouting logic of DockPanel. However, it is possible that at some point in the future, that will not be enough any more and someone will indeed have to write custom layouting logic in my panel. In that case, replacing an inner DockPanel with custom layouting code is trivial, but removing DockPanel from the inheritance hierarchy of my panel would mean a breaking change.
  • If my panel inherits from DockPanel, users of the panel might be able to sabotage its layouting code by messing around with properties exposed by DockPanel, in particular LastChildFill. And while it is just that property, I would like to use an approach that works with all Panel subtypes. For instance, a custom panel derived from Grid would expose the ColumnDefinitions and RowDefinitions properties, by which any automatically generated layout could be completely destroyed via the public interface of the custom panel.

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:

  • Can any one of my attempts be fixed to lead to a completely correct solution? Or is there a completely other way that is preferrable to what I have tried to do what I am looking for?
like image 218
O. R. Mapper Avatar asked Jun 07 '15 14:06

O. R. Mapper


2 Answers

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.

like image 164
Arie Avatar answered Nov 15 '22 18:11

Arie


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

like image 29
Grx70 Avatar answered Nov 15 '22 18:11

Grx70