Search code examples
c#wpfmousecapture

c# WPF - How to recognize mouse click outside control without blocking other controls from being clickable


I now read many threads and tried and so on. I don't get it.

Question When using MouseCapture any other control isn't motioned. Cannot click something. No Highlightings happens on mouse over. MouseCaption is blocking that. Clicking Twice is necessary. How to avoid that?

Basically I created a custom autocompletebox which consists of a textbox for free input and a dropdown as textblock containing suggested result-element by the given input. This is pretty like a standard ComboBox.

From Combobox we know, when it is expanded and one clicks somewhere else the dropdown is collapsed.

I want exactly the same behavior like the combobox uses.

Because I am not the first one asking that, I tried several things but didn't get them fully working.

What I still tried and I failed with.

  1. Adding OnLostFocus event to the textbox does not recognize any mouse clicks to non-focusable Elements.
  2. Using Mous.Caption(this) with PreviewMouseLeftButtonDown to receive any mouse clicks on any place on the window.
    • Yeah that works! I can collapse my dropdown, Un-Capture the mouse again.
    • But: The mouse caption prevents me from clicking to other UIElement. Checkboxes and RadioBoxes wont be toggled. Just hovering the mouse over a Checkbox or anything else is not highlighting that element any more. Instead I now need to click twice to check a textbox.
    • I can't figure out, how to solve that.
  3. Also what did not work was that when the mouse-capture event was fired, I cannot figure out where the click has been made.
    • source as well as e.OriginalSource are equal to my custom control
    • Getting the Mouse-Position may be an option. But did not find to get the position of my control related to the mouse-position. any properties on the control return NaN.
  4. At first I was not able to recognize any difference between PreviewMouseLeftButtonDown and MouseLeftButtonDown.
    • I thought the first one, when directly releasing mouse-capture, would fire the mouseclick event to its original destination without the mouse capture any more. It doesn't.
    • I got it by using the Hittest. Is it that way to do so?

Some Code

XAML of AutoCompleteBox

<UserControl x:Class="MyProject.Wpf.Application.Control.AutoCompleteBoxControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Name="AutoCompleteBox"
             PreviewMouseLeftButtonDown="AutoComplete_MouseLeftButtonDown">
    <Grid>
        <StackPanel>
            <TextBox Name="AutoCompleteTextBox" Text="{Binding ElementName=AutoCompleteBox, Path=TextBoxText, Mode=TwoWay}" Height="{Binding ElementName=AutoCompleteBox, Path=TextBoxHeight}" Width="{Binding ElementName=AutoCompleteBox, Path=TextBoxWidth}" Padding="5, 3, 5, 3" KeyUp="AutoCompleteTextBoxControl_KeyUp" LostKeyboardFocus="AutoCompleteTextBox_OnLostKeyboardFocus"/>
            <Popup Name="ResultStackPopup" IsOpen="True" PlacementTarget="{Binding ElementName=AutoCompleteTextBox}" Placement="Custom">
                <Border Name="ResultStackBorder" Width="{Binding ElementName=AutoCompleteBox, Path=SuggestionListWidth}" Height="{Binding ElementName=AutoCompleteBox, Path=SuggestionListHeight}" BorderBrush="Black" BorderThickness="1" Visibility="Collapsed" Background="White" Margin="1,0,1,0" HorizontalAlignment="Left">
                    <ScrollViewer VerticalScrollBarVisibility="Auto">
                        <StackPanel Name="ResultStack"></StackPanel>
                    </ScrollViewer>
                </Border>
            </Popup>
        </StackPanel>
    </Grid>
</UserControl>

relevant code behind:

//Whenever the dropdown is expanded, the mouse caption is started:
this.CaptureMouse();

mouse-down event:

private void AutoComplete_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (this.IsMouseCaptured)
        {
            if (!this.IsMouseInBounds())
            {
                //Release MouseCaption
                this.ReleaseMouseCapture();

                //Collapse SuggestionList
                var border = (this.ResultStack.Parent as ScrollViewer)?.Parent as Border;
                if (border != null)
                {
                    border.Visibility = Visibility.Collapsed;
                }
            }
        }
    }

private bool IsMouseInBounds()
{
    Point point = Mouse.GetPosition(this);

    // Perform the hit test against a given portion of the visual object tree.
    HitTestResult result = VisualTreeHelper.HitTest(this, point);

    if (result != null)
    {
        return true;
    }

    return false;
}

Edit

Unfortunately the Popup is (afaik) not a member of the visualTree. So the hittest for the Popup does not work. So I tried to get the position of the Popup to check with the Mouse-position.

The TransformToAncestor method is to be used as everyone is saying that. But this does not seem to work properly:

The following three calls do return exactly the same points:

    Window parentWindow = Window.GetWindow(this);
    Point relativePointThis = this.TransformToAncestor(parentWindow).Transform(new Point(0, 0));
    Point relativePointPopup = this.ResultStackPopup.TransformToAncestor(parentWindow).Transform(new Point(0, 0));
    Point relativePointBorder = this.ResultStackBorder.TransformToAncestor(parentWindow).Transform(new Point(0, 0));

Is this a bug?


Solution

  • I did not give up and made some steps forward.

    Answers to:

    1) Either make the Window Focusable or even don't do it that way.

    2) Using the right CaptureMode by Mouse.Capture(this, CaptureMode.SubTree); at least solves that the DropDown keeps being interactive with mouse-over etc. (ofcourse, because it's a child of the capturing-control).

    The rest of the Window keeps beeing blocked by mouse-capture. One may just realizes, that this is also normal behavior for a standard ComboBox. So one could live with that.

    3) Getting the inbound-click-check via the mouse position can be handled with the following code. Notice that the mouse-position is given relative to the given element. In this case the AutoCompleteBox is given itself via this. So the upper left corner will be {0.0, 0.0}.

    private bool IsMouseInBounds()
    {
        //Get MousePosition relative to the AutoCompleteBox
        Point point = Mouse.GetPosition(this);
    
        //Actual Width and Heigth of AutoCompleteBox
        var widthAuto = this.ActualWidth;
        var heightAuto = this.ActualHeight;
        var upLeftXAuto = 0.0;
        var upLeftYAuto = 0.0;
        var downRightXAuto = upLeftXAuto + widthAuto;
        var downRightYAuto = upLeftYAuto + heightAuto;
    
        //Actual Width and Height of DropDown
        var widthDropDown = this.ResultStackBorder.ActualWidth;
        var heightDropDown = this.ResultStackBorder.ActualHeight;
        double upLeftXDropDown;
        double upLeftYDropDown;
    
        //Actual Position of DropDown (may be aligned right or center)
        CalculateAlignmentForPopUp(out upLeftXDropDown, out upLeftYDropDown, 0.0, 0.0, widthAuto, heightAuto, widthDropDown);
    
        var downRightXDropDown = upLeftXDropDown + widthDropDown;
        var downRightYDropDown = upLeftYDropDown + heightDropDown;
    
        //Calc IsInbound
        return (
                 point.X >= upLeftXAuto && point.X <= downRightXAuto &&
                 point.Y >= upLeftYAuto && point.Y <= downRightYAuto
             ) || (
                 point.X >= upLeftXDropDown && point.X <= downRightXDropDown &&
                 point.Y >= upLeftYDropDown && point.Y <= downRightYDropDown
             );
    }
    
    
    
    private void CalculateAlignmentForPopUp(out double newX, out double newY, double xAuto, double yAuto, double widthAuto, double heightAuto, double widthPopup)
    {
        newX = 0.0;
        newY = 0.0;
    
        switch (this.ListHorizontalAlignment)
        {
            case "Right":
                newX = xAuto + widthAuto - widthPopup;
                newY = yAuto + heightAuto;
                break;
            case "Center":
                newX = xAuto + widthAuto / 2 - widthPopup / 2;
                newY = yAuto + heightAuto;
                break;
            default:
                newY = yAuto + heightAuto;
                break;
        }
    }
    

    4) No answer on that. But this is now obsolete when using the solution from 3)

    Edit) No answer on that. But it is working with any way.