Wednesday, August 24, 2011

The curious world of WPF Popups

Popups in WPF sometimes appear to behave not as expected. Mostly it’s just because they are not used correctly or there’s just not enough understanding of the popups functionality and the relations to other controls. I encountered such a situation lately in a project and thought it would be worth to share some thoughts on this topic.

First of all it needs to be clear that a popup is not a window in terms of WPF. This aspect is important because a popup cannot receive focus inside an application. This again is relevant in order to understand how the StaysOpen property of the Popup class works and why a popup sometimes is not closed or opened as expected. Let’s have a look at a first example. Suppose we have the following XAML code:

<ToggleButton x:Name="toggleButton"
              HorizontalAlignment="Center"
              VerticalAlignment="Center" 
              Content="Popup"/>
<Popup IsOpen="{Binding ElementName=toggleButton, Path=IsChecked}"
        Placement="MousePoint"
        StaysOpen="False">
</Popup>

What would you expect to happen here? Will the popup be closed when the toggle button gets unchecked? No, it will not. When the user clicks the toggle button it changes its state to “checked”. This opens the popup, because the popups IsOpen property is bound to the toggle buttons IsChecked property. When the toggle button is clicked again the following happens:
1. The toggle button receives focus. Therefore the popup is closed, because StaysOpen is set to false.
2. The IsOpen property is set to false.
3. Because of the implicit two-way binding between IsOpen and IsChekched, the “checked” status of the toggle button is set to false.
4. The toggle button receives the click event and gets checked, resulting in an open popup.

The simple solution to that problem is to define the binding above as one-way, so the closing of the popup would not change the “checked” state of the toggle button. Another possibility would be to set StaysOpen to true. This results in not closing the popup automatically when the toggle button receives focus. It is closed not until IsChecked changes.

Another problem may be that you have a popup within a popup. Imagine a popup where you have several other controls and maybe some of the controls open another popup which is then displayed over the existing popup. In this situation you would expect to close the “child” popup by clicking on the “parent” popup. But that’s just not possible with the build in functionality. Remember the statement from the beginning: Popups can’t receive focus. Therefore the StaysOpen property won’t work, because the parent popup doesn’t get the focus. This in turn means that there is no way to automatically close the child popup by clicking somewhere on the parent popup. One solution to that problem is to listen to mouse events on the application window and to route these information to “child” popups. We will see this in the following simple example:

<Window x:Class="WpfPopupTestApp.MainWindow"
        Title="MainWindow" Height="350" Width="525">
    <StackPanel Height="29">
    <ToggleButton x:Name="toggleButton"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center"
                  Content="Popup"/>
    <Popup IsOpen="{Binding ElementName=toggleButton, Path=IsChecked}"
           Placement="MousePoint"
           StaysOpen="True">
      <StackPanel Width="100" Height="100">
        <ToggleButton x:Name="togglePopupButton"
                      HorizontalAlignment="Center"
                      Content="Popup"/>
        <Popup x:Name="popup2"
               IsOpen="{Binding ElementName=togglePopupButton, Path=IsChecked}"
  Placement="MousePoint"
               StaysOpen="False"
               Opened="popup2_Opened">
          <StackPanel Width="50" Height="50">
            <Label Content="Test"/>
          </StackPanel>
        </Popup>
      </StackPanel>
    </Popup>
  </StackPanel>
</Window> 

This small XAML snippet defines a toggle button that can be used to open a popup. Inside the popup is another toggle button that opens another popup inside the parent popup. The StaysOpen property of both popups is set to false so that they are automatically closed when a control receives focus. With the current setup, the child popup lacks this functionality. That’s why it needs a manual trigger. As mentioned above we will start listening on mouse events – concretely the PreviewMouseUp event – of the application window. In the event handler we will invoke previously registered callback methods to inform child popups of the event. The following additions have to be made to the application main window:

private IList<MainWindowMouseDownCallback> callbacks = new List<MainWindowMouseDownCallback>(); 

public delegate void MainWindowMouseDownCallback(object sender, MouseButtonEventArgs args); 

public MainWindow()
{
    InitializeComponent();
    this.PreviewMouseUp += MainWindow_MouseUp;
} 

public void RegisterMouseDownCallback(MainWindowMouseDownCallback callback)
{
    callbacks.Add(callback);
} 

public void UnRegisterMouseDownCallback(MainWindowMouseDownCallback callback)
{
    if (callbacks.Contains(callback))
    {
        callbacks.Remove(callback);
    }
} 

void MainWindow_MouseUp(object sender, MouseButtonEventArgs e)
{
    foreach (var callback in callbacks)
    {
        callback.Invoke(sender, e);
    }
}

We define our own special callback delegate for this situation. Child popups may register such a callback to get informed of mouse up events. Pay attention to use PreviewMouseUp and not just MouseUp. Otherwise you may end up with someone already handling the mouse event so you don’t get notified. In order to clean up properly we also added a method to unregister callbacks. Let’s have a look at what needs to be done in the popup:

private void popup2_Opened(object sender, EventArgs e)
{
    var parent = GetParent(popup2, typeof(Popup));
    if (parent != null)
    {
        this.RegisterMouseDownCallback(PopupCallback);
        popup2.Closed += popup2_Closed;
    }
} 

private void popup2_Closed(object sender, EventArgs e)
{
    popup2.Closed -= popup2_Closed;
    this.UnRegisterMouseDownCallback(PopupCallback);
}

private void PopupCallback(object sender, MouseButtonEventArgs args)
{
    var obj = args.Source;
    if (obj != null)
    {
        var parent = GetParent(obj as DependencyObject, typeof(Popup));
        if (parent != null && !parent.Equals(popup2))
        {
            this.popup2.IsOpen = false;
        }
    }
}

When the popup opens, it adds its callback to the application main window. It does this only if it has a parent control in its logical tree which is a popup too. Additionally it subscribes to its own close event in order to properly unregister the callback. In the callback we’re looking at the source of the mouse event. If the source or one of its parents in the logical tree is a popup and this popup is not the child popup itself, then we close the child popup. There is no magic at all. To find a parent control of a given type in the logical tree the GetParent method is used. This method is implemented as follows:

private static DependencyObject GetParent(DependencyObject current, Type type)
{
    var parent = LogicalTreeHelper.GetParent(current);
    if (parent == null)
        return null; 

    if (parent.GetType().Equals(type))
    {
        return parent;
    }
    return GetParent(parent, type);
}

This method uses the LogicalTreeHelper to walk up the logical tree until it finds an element of the given type. This element is then returned. With these additions to the popup and the main window we’re now able to close a child popup when we click anywhere on the parent popup.
This was a very small and simple example and I’m sure that everyone is conflicted with more complex problems and applications but I hope it gives you a starting point when you’re entering the curious world of WPF popups.

1 comment:

  1. It is important to note that both the Popup and its relevant PlacementTarget property value should be declared on the save visual tree, unless the Popup will position it self unexpectedly.

    ReplyDelete