How to Reuse Existing Layouting Code for new Panel Class?
Asked Answered
I

2

12

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?
Infatuated answered 7/6, 2015 at 14:18 Comment(16)
Could someone explain the downvote, please?Infatuated
How does the layout of your custom panel differ from that of a DockPanel? Maybe you can derive from DockPanel, and implement the differences in MeasureOverride and ArrangeOverride after calling the methods of the base class.Gemot
@Clemens: I would like to control the layout with different attached properties than those of DockPanel. Internally, when setting one of my own attached properties, the DockPanel properties should be set. However, I intend for that to be an implementation detail; it is well possible that, for instance, the inner DockPanel will be replaced with a Grid in future versions (and if that happens, I still don't want the ColumnDefinitions and RowDefinitions to be visible externally).Infatuated
@Clemens: Thank you for the remark in any case, I have added the suggestion to the question.Infatuated
Just copy the necessary parts of the DockPanel layout code to your Panel class?Gemot
@Clemens: Wouldn't that kind-of legally limit what I can do with the resulting code, if parts of it are copied from non-free sources?Infatuated
Part of .NET is already open source. You will have to check the license terms, but I don't think there will be any limitation on how your control may be used.Gemot
@Clemens: Are you referring to Reference Source? Its license says: "specifically excludes the right to distribute the software outside of your company". The MIT-licensed .NET Core doesn't include WPF (yet?).Infatuated
Yet, I guess... Anyway, from the reference source you can get the idea of how it works and impliment it similarly. It shouldn't be too much code to re-implement.Gemot
I can't think of a better non-hacky option. Panels are Panels after all, they don't have inner panels, or Templates, they just define a layouting logic. If part of that logic is similar to the DockPanel's logic, it's just logical (no pun intended) to reproduce part of its code.Candlelight
@almulo: Somehow, copy-and-pasting larger amounts of code sound suboptimal to me; also, DockPanel is still fairly simple, but replicating the layouting logic of a Grid sounds like a major task.Infatuated
Well, what can I say... Creating custom layout elements is never an easy task in WPF. The 100% RIGHT way would be creating a Panel from scratch. Replicating code from the reference source is just our way to save you that much work. If you prefer to pass on the Panel thing (understandably), then it's eaither a custom ItemsControl (with Items instead of Children), or some hacky hybrid class like approach 2.Candlelight
I'm having a really hard time understanding WHY you want to do this, and WHY you need these hard requirements and worries about "abusing" public properties. If you provide the reasons for attempting this you can probably get better help solving thisHammad
@Zache: I am somewhat surprised one would require an extra justification for general good practices such as "reusing existing code rather than duplicating it" or "properly encapsulating functionality by disallowing outside code from accessing any members that might interfer with the intended behaviour", but I will try to clarify that.Infatuated
@O.R.Mapper It just seems to me that you are trying to do something hard and complex when you could do something much more simple. If you are building a control library to sell I sort of understand your needs, but if you are just distrustfull of your colleagues I don't ;)Hammad
@Zache: Maybe we are following different development philosophies, but as a rule of thumb, I tend to apply exactly the same standards of encapsulation, information hiding, and maintainability to APIs and code that I am just using myself as to APIs and code that I sell to customers. Though, as a matter of fact, this panel is supposed to be part of a control library that will be used by strangers.Infatuated
B
2

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://mcmap.net/q/99737/-how-can-i-find-wpf-controls-by-name-or-type


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.

Bitterweed answered 1/7, 2015 at 7:47 Comment(8)
But then users of the control would have to know that for some reason, the usual way of binding to a child control (by {Binding ElementName=innerPanelButtonName, Path=Content}) does not work and has to be worked around with the index notation suggested in this answer, which looks like a bug. After all, no other WPF panel requires such a syntax, all of them work directly with ElementName.Infatuated
it is possible to enable "normal" ElementName binding in this scenario. You have to register names of controls in yout innerPanel in the NameScope of your root UI element. Now the problem will be how to find it reliably (e.g. Application.Current.MainWindow will return null during design time). I'm updating my answer...Bitterweed
The edit looks promising. I suppose it evokes the question why the children of the inner panel are not in the same name scope as those of the outer panel, or whether and how we can enforce the same name scope for the two (otherwise, I assume things would get ugly when names change at runtime, which I presume the built-in default handling of name scopes takes care of). The panels themselves (even the outer ones) do not seem to have any name scope set; however, Snoop tells me not only the host Window, but also the Border directly nested into that window has a non-null name scope.Infatuated
I have extended the description of my approach 2) to incorporate the valuable information about NameScope retrieved from your answer.Infatuated
Also, I have launched a separate question that might provide some clues as to how to retrieve and update the appropriate NameScope.Infatuated
The NameScope was a good tip - I was able to assign a custom INameScope implementation to the inner panel, which forwards any calls to the namescope found by scanning the logical tree from the outer panel upwards. I will do some more tests to verify whether this really works.Infatuated
@O.R.Mapper pleas share the solution to the NameScope issue, even if it doesn't ultimately solve your problem. This is very interesting and seems useful, but I didn't find any reliable way to use NameScope programmatically on the net.Bitterweed
I have taken the liberty to edit your answer in order to append a brief description of what I finally did.Infatuated
R
2

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

Reflux answered 6/7, 2015 at 16:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.