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
isfalse
, thePopup
control intercepts all mouse and keyboard events to determine when one of these events occurs outside thePopup
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:
-> 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 .
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 ToggleButton
and 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 the
ComboBox` 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>