سؤال

I have an adorner template which contains this:

<ControlTemplate x:Key="myAdornerTemplate">
  <Canvas x:Name="canvas">
     <Line X1="0" Y1="0" X2="(?)" Y2="(?)"/>
     <DockPanel x:Name="root" >
        <AdornedPlaceHolder HorizontalAlignment="Left"/>
     </DockPanel>
  </Canvas>
</ControlTemplate>

I want my line to always 'connect' with the adorned placeholder visually, which moves at runtime with respect to the canvas. The dockpanel might at sometime also move independently of the adornedplaceholder. How can I bind to the position of the AdornedPlaceHolder with respect to the Canvas? (I cannot rely on the dockpanel because it moves independently nor take my placeholder out of it).

هل كانت مفيدة؟

المحلول

1. The problem

(If you are not interested in all that chatter and want just to see the code, you can skip to section 2.)

To get the position (a-ka upper left corner) of a control relative to another control which is not the parent, you can do the following:

Point posCtrl1 = control1.PointToScreen(new Point(0, 0));
Point posCtrl2 = control2.PointToScreen(new Point(0, 0));

Point positionOfControl1RelativeToControl2 =
    new Point(posCtrl1.X - posCtrl2.X, posCtrl1.Y - posCtrl2.Y);

This is okay if you don't need to dynamically update positionOfControl1RelativeToControl2 whenever the two controls change position relative to each other.

But if you do, you have a problem: How will know when the location (a-ka screen coordinates) of control1 or control2 changes, so that the relative coordinates can be recalculated. And how can it be done in ControlTemplate-friendly XAML?

Fortunately, UIElement offers the LayoutUpdated event, which fires when the location or size of a UIElement changes.

Well, that is not really exactly true. Unfortunately, it is quite a special event, firing not only if something regarding the UIElement happened but every time some layout change happened anywhere in the tree. Even nastier, the LayoutUpdated event doesn't provide a sender (the sender argument being just null). The reasons behind this are explained here.

The special attitude of LayoutUpdated requires our code to keep track of the controls we want to get the screen coordinates from when such a LayoutUpdated event fires.

Note: While the linked blog post refers to Silverlight, i have found the same to be true in "ordinary" WPF. However, i still recommend to verify whether the approach laid out here will work for your code.

But, on top of that is one more obstacle: How will we tell in XAML which UIElement should be tracked for screen coordinates (we don't want to track each and every UIElement in the GUI as this could cause severe performance degradation), and how will we be able to get and bind against those screen coordinates?

Attached properties come to the rescue. We will need two attached properties. One for enabling/disabling tracking of screen coordinates, and another read-only attached property for providing the screen coordinates.


2. ScreenCoordinates.IsEnabled: An attached property for enabling/disabling screen coordinate tracking

Note: All code should be placed in a static class named ScreenCoordinates (since the attached properties refer to this class name).

The boolean attached property ScreenCoordinates.IsEnabled will enable/disable screen coordinate tracking for the UIElement it is set at.

It will also take care of adding and removing the respective UIElement to/from a collection which keeps track of the UIElements we want to get screen coordinates from.

The code for the attached property is rather straightforward:

public static readonly DependencyProperty IsEnabledProperty =
    DependencyProperty.RegisterAttached(
        "IsEnabled",
        typeof(bool),
        typeof(ScreenCoordinates),
        new FrameworkPropertyMetadata(false, OnIsEnabledPropertyChanged)
    );

public static void SetIsEnabled(UIElement element, bool value)
{
    element.SetValue(IsEnabledProperty, value);
}
public static bool GetIsEnabled(UIElement element)
{
    return (bool) element.GetValue(IsEnabledProperty);
}

private static void OnIsEnabledPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if ((bool) e.NewValue)
        AddTrackedElement((UIElement) d);
    else
        RemoveTrackedElement((UIElement) d);
}

The code that handles the actual collection of tracked UIElements has to consider two things.

First, WeakReference has be used for storing the UIElements in the collection. This allows GC'ing of UIElements discarded by the GUI although their WeakReference is still stored in the collection. Without weak references, the code would not really have a practical way to determine if a UIElement is still used by the GUI or not, possibly causing a memory/resource leak.

Secondly, the collection will be enumerated during a LayoutUpdated event, which - through the data bindings against the actual screen coordinates (we will come to that a little later) - could potentially trigger user code changing a ScreenCoordinates.IsEnabled property, which would change the collection and thus screw up the enumeration in our LayoutUpdated event handler.

The solution to this is two have a queue, where we any AddTrackedElement and RemoveTrackedElement invocations thathappens during processing of an LayoutUpdated event will be "parked". At the end of the LayoutUpdated event, the actions "parked" in that queue are finally processed (we'll see that later when explaining the second attached property).

//
// We define a custom EqualityComparer for the HashSet<WeakReference>, which
// treats two WeakReference instances as equal if they refer to the same target.
//
private class WeakReferenceTargetEqualityComparer : IEqualityComparer<WeakReference>
{
    public bool Equals(WeakReference wr1, WeakReference wr2)
    {
        return (wr1.Target == wr2.Target);
    }

    public int GetHashCode(WeakReference wr)
    {
        return wr.GetHashCode();
    }
}

private static readonly HashSet<WeakReference> _collControlsToTrack =
    new HashSet<WeakReference>(new WeakReferenceTargetEqualityComparer());

private static readonly List<Action> _listActionsToRunWhenOnLayoutUpdatedCompletes = new List<Action>();
private static bool _isCollControlsToTrackEnumerating = false;

private static void AddTrackedElement(UIElement uiElem)
{
    if (_isCollControlsToTrackEnumerating)
    {
        lock (_listActionsToRunWhenOnLayoutUpdatedCompletes)
        {
            _listActionsToRunWhenOnLayoutUpdatedCompletes.Enqueue(() => AddTrackedElement(uiElem));
        }
        return;
    }

    lock (_collControlsToTrack)
    {
        // Remove all GC'ed UIElements from _collControlsToTrack and then add the given UIElement
        _collControlsToTrack.RemoveWhere(wr => !wr.IsAlive);
        _collControlsToTrack.Add(new WeakReference(uiElem));
    }
}

private static void RemoveTrackedElement(UIElement uiElem)
{
    if (_isCollControlsToTrackEnumerating)
    {
        lock (_listActionsToRunWhenOnLayoutUpdatedCompletes)
        {
            _listActionsToRunWhenOnLayoutUpdatedCompletes.Enqueue(() => RemoveTrackedElement(uiElem));
        }
        return;
    }

    lock (_collControlsToTrack)
    {
        // Remove all GC'ed UIElements from _collControlsToTrack and then remove the given UIElement
        _collControlsToTrack.RemoveWhere(wr => !wr.IsAlive);
        _collControlsToTrack.Remove(new WeakReference(uiElem));
    }
}


3. ScreenCoordinates.TopLeft: Read-only attached property providing the screen coordinates

Note: All code should be placed in a static class named ScreenCoordinates (since the attached properties refer to this class name).

The attached property ScreenCoordinates.TopLeft providing the screen coordinates is read-only, because it obviously doesn't make sense trying to set it (WPF's layout system and the used panels/containers will control positioning of UIElements).

ScreenCoordinates.TopLeft property returns the screen coordinates as Point type, and its related code is rather simple:

public static readonly DependencyPropertyKey TopLeftPropertyKey =
    DependencyProperty.RegisterAttachedReadOnly(
        "TopLeft",
        typeof(Point),
        typeof(ScreenCoordinates),
        new FrameworkPropertyMetadata(new Point(0,0))
    );

public static readonly DependencyProperty TopLeftProperty = TopLeftPropertyKey.DependencyProperty;

private static void SetTopLeft(UIElement element, Point value)
{
    element.SetValue(TopLeftPropertyKey, value);
}
public static Point GetTopLeft(UIElement element)
{
    return (Point) element.GetValue(TopLeftProperty);
}

That was easy. Oh wait... there is still the code missing which handles the LayoutUpdated event and feeds the screen coordinates into this attached property.

To receive LayoutUpdated events, we will use our own private UIElement. It will never be shown in the UI and not interfere with the rest of the program whatsoever. The good thing is, it still provides us with the LayoutUpdated event, and we don't need to rely on whatever particular UIElements are being used by the GUI at any moment.

private static UIElement _uiElementForEvent;

static ScreenCoordinates()
{
    Application.Current.Dispatcher.Invoke( (Action) (() => { _uiElementForEvent = new UIElement(); }) );
}

The code in the static constructor of the class ScreenCoordinates ensures that *_uiElementForEvent* will be created on the UI thread.

We are almost done. What remains to do is to implement the event handler for the LayoutUpdated event. (Note the usage of _isCollControlsToTrackEnumerating in relation to its use in the AddTrackedElement and RemoveTrackedElement methods.)

private static void OnLayoutUpdated(object s, EventArgs e)
{
    if (_collControlsToTrack.Count > 0)
    {
        bool doesCollectionHaveGCedElements = false;

        _isCollControlsToTrackEnumerating = true;
        lock (_collControlsToTrack)
        {
            foreach (WeakReference wr in _collControlsToTrack)
            {
                UIElement uiElem = (UIElement)wr.Target;
                if (uiElem != null)
                    SetTopLeft(uiElem, uiElem.PointToScreen(new Point(0, 0)));
                else
                    doesCollectionHaveGCedElements = true;
            }

            //
            // If any GC'ed elements where encountered during enumeration
            // of _collControlsToTrack, then purge the collection from them.
            // In the vast majority of LayoutUpdated events, the UIElements
            // in the collection should be alive. Thus, the performance
            // impact of this code should be (hopefully) negligible.
            //
            if (doesCollectionHaveGCedElements)
                _collControlsToTrack.RemoveWhere(wr => !wr.IsAlive);

            _isCollControlsToTrackEnumerating = false;

            //
            // If there were any AddTrackedElement or RemoveTrackedElement action queued while
            // OnLayoutUpdated was enumerating _collControlsToTrack, then execute them now.
            // (Note that synchronization via _collControlsToTrack is still in effect, thus invocations of
            // AddTrackedElement or RemoveTrackedElement by other threads cannot interleave with the
            // order of actions.
            //
            lock (_listActionsToRunWhenOnLayoutUpdatedCompletes)
            {
                foreach (Action a in _listActionsToRunWhenOnLayoutUpdatedCompletes)
                    a();

                _listActionsToRunWhenOnLayoutUpdatedCompletes.Clear();
            }

            if (_collControlsToTrack.Count == 0)
            {
                _uiElementForEvent.LayoutUpdated -= OnLayoutUpdated;
                _isOnLayoutUpdatedAttachedToEvent = false;
            }
        }
    }
}

The last bit to do is to add the event handler to the event...


4. Attaching the event handler to the event - AddTrackedElement/RemoveTrackedElement revisited

Since the LayoutUpdated event can fire rather often, it would make sense to have the event handler attached to the event only when there are UIElements to track. So let's get back to to the methods AddTrackedElement and RemoveTrackedElement and apply the necessary modifications:

private static bool _isOnLayoutUpdatedAttachedToEvent = false;

private static void AddTrackedElement(UIElement uiElem)
{
    if (_isCollControlsToTrackEnumerating)
    {
        lock (_listActionsToRunWhenOnLayoutUpdatedCompletes)
        {
            _listActionsToRunWhenOnLayoutUpdatedCompletes.Enqueue(() => AddTrackedElement(uiElem));
        }
        return;
    }

    lock (_collControlsToTrack)
    {
        // Remove all GC'ed UIElements from _collControlsToTrack and then add the given UIElement
        _collControlsToTrack.RemoveWhere(wr => !wr.IsAlive);
        _collControlsToTrack.Add(new WeakReference(uiElem));

        if (!_isOnLayoutUpdatedAttachedToEvent)
        {
            _uiElementForEvent.LayoutUpdated += OnLayoutUpdated;
            _isOnLayoutUpdatedAttachedToEvent = true;
        }
    }
}

private static void RemoveTrackedElement(UIElement uiElem)
{
    if (_isCollControlsToTrackEnumerating)
    {
        lock (_listActionsToRunWhenOnLayoutUpdatedCompletes)
        {
            _listActionsToRunWhenOnLayoutUpdatedCompletes.Enqueue(() => RemoveTrackedElement(uiElem));
        }
        return;
    }

    lock (_collControlsToTrack)
    {
        // Remove all GC'ed UIElements from _collControlsToTrack and then remove the given UIElement
        _collControlsToTrack.RemoveWhere(wr => !wr.IsAlive);
        _collControlsToTrack.Remove(new WeakReference(uiElem));

        if (_isOnLayoutUpdatedAttachedToEvent && _collControlsToTrack.Count == 0)
        {
            _uiElementForEvent.LayoutUpdated -= OnLayoutUpdated;
            _isOnLayoutUpdatedAttachedToEvent = false;
        }
    }
}

Note the boolean variable _isOnLayoutUpdatedAttachedToEvent, which indicates whether the event handler is currently attached or not.


5. How does all that relate to your question?

Now, i have to admit that i still don't understand exactly where you want to have the start point and end point of the line in relation to your AdornerPlaceholder.

Hence, for the following example i assume the start point of the line being at the upper left corner of the AdornerPlaceholder, and line end point being at the bottom right corner.

(Note that contrary to the code above, i have not tested the following code snippets. My apologies if they would contain any errors. But i hope you get the idea...)

<ControlTemplate x:Key="myAdornerTemplate">
  <Canvas x:Name="canvas">
     <Line>
        <Line.X1>
            <MultiBinding Converter="{StaticResource My:ScreenCoordsToVisualCoordsConverter}" ConverterParameter="X" >
                <Binding ElementName="canvas"/>
                <Binding ElementName="ado" Path="(My:ScreenCoordinates.TopLeft)"/>
            </MultiBinding>
        </Line.X1>
        <Line.Y1>
            <MultiBinding Converter="{StaticResource My:ScreenCoordsToVisualCoordsConverter}" ConverterParameter="Y" >
                <Binding ElementName="canvas"/>
                <Binding ElementName="ado" Path="(My:ScreenCoordinates.TopLeft)"/>
            </MultiBinding>
        </Line.Y1>

        <Line.X2>
            <MultiBinding Converter="{StaticResource My:AdditionConverter}">
                <Binding ElementName="canvas" Path="X1" />
                <Binding ElementName="ado" Path="ActualWidth"/>
            </MultiBinding>
        </Line.X2>
        <Line.Y2>
            <MultiBinding Converter="{StaticResource My:AdditionConverter}">
                <Binding ElementName="canvas" Path="Y1" />
                <Binding ElementName="ado" Path="ActualHeight"/>
            </MultiBinding>
        </Line.Y2>
     </Line>
     <DockPanel x:Name="root" >
        <AdornedPlaceHolder x:Name="Ado" HorizontalAlignment="Left"/>
     </DockPanel>
  </Canvas>
</ControlTemplate>

Some words about the converters used in this example XAML

AdditionConverter takes just the numerical values from the bindings, adds them and should return them as double (according to the target type of the MultiBinding).

ScreenCoordsToVisualCoordsConverter converts a point in screen coordinates into a point in the local coordinate system of a Visual (UIElement). Hence it wants to be provided with two values: The first value is the Visual, the second value is the point in screen coordinates. The logic of this converter would look like this:

Visual v = (Visual) values[0];
Point screenPoint = (Point) values[1];

Point pointRelativeToVisual = v.PointFromScreen(screenPoint);

The ConverterParameter parameter simply defines whether the X or the Y coordinate of pointRelativeToVisual is being returned.


6. Some notes

  1. If possible try to avoid the approach i explained here - use it only if you have no other options and you really, really must use it (there is almost always another, better way of how to fiddle with your UI - like in your case, perhaps try to restructure your GUI and GUI-related logic so that you can have the Line shape and the AdornerPlaceholder both as children of the Canvas). If you still decide to use it, use it sparsely.

    Because of the LayoutUpdated event firing whenever something has changed regarding the layout anywhere in your WPF GUI, it can be fired rather often and in quick succession. Careless application of the code i have given here might result in much and mostly unnecessary processing of LayoutUpdated events, causing your GUI being as speedy as a frozen snail.

  2. The code as depicted above bears the risk of a deadlock in quite esoteric but nevertheless possible circumstances.

    Imagine a non-UI thread calling AddTrackedElement and it is just about to execute the lock (_collControlsToTrack) statement. However, a LayoutUpdated event is just now processed on the UI thread and OnLayoutUpdated locks _collControlsToTrack just a moment earlier. Naturally the non-UI thread gets blocked at the lock statement, waiting for OnLayoutUpdated relasing the lock.

    Now imagine, you have bound one of your dependency properties to ScreenCoordinates.TopLeft. And that dependency property has a PropertyChangedCallback, which will wait for a signal from that aformentioned non-UI thread. But that signal will never come, because the Non-UI thread waits in AddTrackedElement and the UI thread hangs in the PropertyChangedCallback, never finishing OnLayoutUpdated -- deadlock.

    The basic idea to avoid that deadlock scenario is to replace lock (_collControlsToTrack) in AddTrackedElement and RemoveTrackedElement with Monitor.Enter(object, bool) to avoid those methods becoming blocked. Additionally, if Monitor.Enter can't acquire the lock, you want to make use of the already existing _listActionsToRunWhenOnLayoutUpdatedCompletes to ensure conflict-free manipulation of _collControlsToTrack.

  3. Depending on the needs, the approach given here might also be incomplete. Although the code deals with screen coordinates, it won't update ScreenCoordinates.TopLeft if you just drag your main window around the desktop. Additional tracking of window positions would require finding the window owning the UIElement and keeping track of its Left and Top properties as well as whether the window is in maximized mode. But that's a story for another dark night and for another question...

مرخصة بموجب: CC-BY-SA مع الإسناد
لا تنتمي إلى StackOverflow
scroll top