Search code examples
wpfpopup

WPF: How to automatically close Popup for corner cases where `StaysOpen="False"` alone is not enough?


I have created a custom time picker control that is opening in a Popup centered on a button and it is working great. The only issue I haven't been able to fix is that when, while the Popup is open and the user either moves the window that contains my control (by dragging its title bar) or when they scroll outside of my control, the Popup does not close and just stays in its original location.

I have set StaysOpen="False" and this is working great for all but the two cases mentioned above: Clicking anywhere in my application (except for the title bar area) will cause the popup to close.

According to the docs:

When StaysOpen is false, the Popup control intercepts all mouse and keyboard events to determine when one of these events occurs outside the Popup control.

However, this does not seem to mean what I thought it would: When I assign an event handler to the Popup's MouseWheel, PreviewMouseWheel, MouseDown, etc. this will never fire for any mouse activity outside of the Popup. Probably the docs mean it is intercepting them internally only? If so, it is failing to catch these corner cases...

I have already read through a ton of accepted answers on this topic here but they all either have no effect for me (for whatever reason) or they are handling this by actually capturing the mouse events outside the control's code and then tell the control to close its Popup. This is not an option for me as I want my control to be reusable on any parent without requiring the consumer to add code for closing the Popup on mouse activity... Everything that is required to get this working needs to be part of my control, either its code or its style/template.

I have kind of come up with a solution to the scrolling problem by walking the visual tree up from my control until I find a ScrollViewer and then attach myself to its ScrollChanged event and that seems to work at least for simple cases but it still feels kinda hacky...

I have not had any idea on how to deal with the title bar issue...

Any more ideas or pointers?

Here's a simplified example that exhibits the same problem:

Class:

[TemplatePart(Name = PopupPartName, Type = typeof(Popup))]
public class PopupButton : ButtonBase
{
    public const string PopupPartName = "PART_Popup";

    private Popup _popup;

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        _popup = GetTemplateChild(PopupPartName) as Popup;
        _popup.Opened += OnPopupOpened;
        _popup.Closed += OnPopupClosed;
    }

    protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
    {
        base.OnMouseLeftButtonDown(e);
        _popup.IsOpen = true;
    }

    protected override void OnLostFocus(RoutedEventArgs e)
    {
        base.OnLostFocus(e);
        _popup.IsOpen = false;
    }

    private void OnPopupOpened(object sender, EventArgs e)
    {
        _popup.PreviewMouseWheel += OnPopupMouseWheel;
        _popup.PreviewMouseLeftButtonDown += OnPopupMouseLeftButtonDown;
    }

    private void OnPopupClosed(object sender, EventArgs e)
    {
        _popup.PreviewMouseWheel -= OnPopupMouseWheel;
        _popup.PreviewMouseLeftButtonDown -= OnPopupMouseLeftButtonDown;
    }

    private void OnPopupMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
    {
        _popup.IsOpen = _popup.IsMouseOver;
    }

    private void OnPopupMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        _popup.IsOpen = _popup.IsMouseOver;
    }
}

XAML:

<Window x:Class="WpfApp2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:local="clr-namespace:WpfApp2"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        Title="MainWindow"
        Width="800"
        Height="450"
        mc:Ignorable="d">
    <Window.Resources>
        <Style x:Key="PopupButtonStyle" TargetType="{x:Type local:PopupButton}">
            <Setter Property="Padding" Value="16 8" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type local:PopupButton}">
                        <Grid>
                            <Border x:Name="TheButton"
                                    Background="{TemplateBinding Background}">
                                <ContentPresenter Margin="{TemplateBinding Padding}"
                                                  TextBlock.Foreground="{TemplateBinding Foreground}" />
                            </Border>
                            <Popup x:Name="PART_Popup"
                                   Width="{Binding ElementName=TheButton, Path=ActualWidth}"
                                   Height="100"
                                   AllowsTransparency="True"
                                   Focusable="True"
                                   Placement="Center"
                                   PopupAnimation="Fade"
                                   StaysOpen="False">
                                <Border Background="DarkBlue">
                                    <TextBlock Margin="{TemplateBinding Padding}"
                                               HorizontalAlignment="Center"
                                               VerticalAlignment="Center"
                                               Text="Whatever" />
                                </Border>
                            </Popup>
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </Window.Resources>
    <ScrollViewer>
        <StackPanel Height="2048">
            <TextBox Width="200"
                     Margin="32"
                     Text="Whatever" />

            <local:PopupButton Margin="32"
                               HorizontalAlignment="Center"
                               VerticalAlignment="Top"
                               Background="Blue"
                               Content="Click here for popup"
                               Foreground="White"
                               Style="{StaticResource PopupButtonStyle}" />

            <Button Margin="32"
                    Padding="16 8"
                    HorizontalAlignment="Center"
                    Content="Something else to click on" />
        </StackPanel>
    </ScrollViewer>
</Window>

Steps:

  1. Click the blue button
  2. Move the window by dragging the title bar - OR:
  3. Scroll the window content, either using the scrollbar or the mouse wheel.

-> The popup will not close in those cases and breakpoints in the OnPopupMouseWheel() or OnPopupMouseLeftButtonDown() methods will not be hit unless you scroll or click inside of the popup.

Just for completeness: My code currently needs to run under .NET Framework 4.8 .


Solution

  • It's weird that you report that the input events are not raised. This is not normal.

    Edit After the poster updated the question to provide a more detailed example it became apparent that the Popup was opened wrong, causing a mouse capture issue that impacted the Popup behavior (see explanation below). After fixing this the popup behaves normally and doesn't require tracking of the parent window's location any more. Clicking outside the Popup while Popup.StaysOpen is false now behaves correctly and closes the Popup without further action.

    Creating your custom Popup and then overriding the relevant mouse event handlers will do the trick.
    To detect window drag you must get the owning Window instance and observe the Window.LocationChanged event
    :

    MyPopup.cs

    public class MyPopup : Popup
    {
      protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)
      {
        base.OnPreviewMouseWheel(e);
        this.IsOpen = this.StaysOpen || this.Child.IsMouseOver;
      }
    }
    

    Now that you have posted an example implementation, I can tell for sure that the issue is not .NET version related. It's just the way you handle the Popup. You have essentially broken the behavior of the Popup.

    Using a simple Button or ButtonBase and handling mouse down/up events to open the Popup (or to react on a button click in general) can be problematic as the ButtonBase does some mouse capture handling internally.

    The ButtonBase has a ButtonBase.IsPressed state. To ensure that the ButtonBase doesn't miss the release state it has to capture the mouse. After ButtonBase is done handling the IsPressed state it converts the mouse down/up event to the ButtonBase.Click event.
    When this event is raised the mouse capture is finally released and other elements like your Popup can continue to receive mouse events like the UIElement.MouseWheel event.

    Now, the problematic part is that the Popup itself requires to acquire the mouse capture. It requires the capture in order to realize the Popup.StaysOpen behavior. When you open the Popup while the ButtonBase still holds the capture, the Popup can't capture mouse too. This results in the odd behavior you observe, for example clicking outside the Popup won't close it.

    This means you must always handle the ButtopnBase.Click event if you want to react to a click on the button. UIElement.MouseDown does not represent a click. In your case, you would simply override the ButtonBase.OnClick method (and remove the ButtonBase.OnMouseLeftButtonDown override.

    My example was overriding the event handler in the Popup class. My example enables the MyPopup to control itself while your example the behavior is externally controlled. From a design perspective I recommend implementing the behavior by extending the Popup the way I did with MyPopup.

    However, to avoid the hassle of caring about the mouse click handling of the Button I recommend to use a ToggleButton (as it is usually done). For example, framework controls like the ComboBox use ToggleButton too. The key is to handle the ToggleButton.IsChecked property or preferably bind it to the Popup.IsOpen property.
    Introducing the ToggleButtonand the related binding makes handling the Popup.Opened and Popup.Closed event redundant to further simplify the implementation of PopupButton.

    Additionally, you don't have to handle mouse clicks outside the Popup as the Popup will close by default when Popup.StaysOpen is set to false. This makes the handling of the LostFocus event unnecessary.
    You only have to take care of the MouseWheel event.

    And because of the acquired mouse capture of the Popup you must also observe the Popup.IsMouseDirectlyOver property instead of Popup.IsMouseOver (because of the captured mouse IsMouseOver would always return true while the Popup has captured the mouse, even when the mouse is not over the Popup while the MouseWheel event is raised).

    The MousWheel event is only raised for the Popup while it is opened and therefore is hooked into a visual tree. Additionally, the Popup is a child of the PopupButton as it is defined inside its ControlTemplate - the Popup can't live longer than the PopupButton. This makes unsubscribing from the MousWheel events on Popup.Closed redundant and allows to further simplify the code of PopupButton.

    Just as a note: you can consider to mark the MouseWheel event as handled (swallow it) instead of closing the Popup. This would mean that the user can't scroll anything outside thePopupuntil it is closed. This is how theComboBox` is behaving, for instance.

    Your fixed and improved code could look as follows:

    PopupButton.cs
    PopupButton now extends ToggleButton (instead of ButtonBase).
    Because we open the Popup correctly now, we allow it to operate properly. We no longer have to care about losing the focus or mouse clicks outside the Popup.
    Now, we only have to take care of the PreviewMouseWheel event:

    public class PopupButton : ToggleButton
    {
      public const string PopupPartName = "PART_Popup";
    
      private Popup _popup;
    
      public override void OnApplyTemplate()
      {
        base.OnApplyTemplate();
        _popup = GetTemplateChild(PopupPartName) as Popup;
    
        // We must always anticipate a template override that doesn't 
        // provide the template part or doesn't name the part correctly. 
        // In this case GetTemplateChild returns NULL.
        if (_popup != null)
        {
          _popup.PreviewMouseWheel += OnPopupMouseWheel;
        }
      }
    
      private void OnPopupMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
      {
        if (_popup != null)
        {
          _popup.IsOpen = _popup.Child.IsMouseOver;
        }
      }
    }
    

    App.xaml

    <Style TargetType="{x:Type local:PopupButton}">
      <Setter Property="Padding"
              Value="16 8" />
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="{x:Type local:PopupButton}">
            <Grid>
              <Border x:Name="TheButton"
                      Background="{TemplateBinding Background}">
                <ContentPresenter Margin="{TemplateBinding Padding}"
                                  TextBlock.Foreground="{TemplateBinding Foreground}" />
              </Border>
    
              <Popup x:Name="PART_Popup"
                     IsOpen="{Binding RelativeSource={RelativeSource Templatedparent}, Path= IsChecked}"
                     Width="{Binding ElementName=TheButton, Path=ActualWidth}"
                     Height="100"
                     AllowsTransparency="True"
                     Focusable="True"
                     Placement="Center"
                     PopupAnimation="Fade"
                     StaysOpen="False">
                <Border Background="DarkBlue">
                  <TextBox Margin="{TemplateBinding Padding}"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Text="Whatever" />
                </Border>
              </Popup>
            </Grid>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>