Imagine the situation you open a WPF Popup
(e.g. through ButtonClick).
You have a ListBox
directly in the Popup
with some items, so you have to be able to scroll.
Imagine that this is your Custom Control
and it's located in the ScrollViewer
.
Now if you move with your mouse outside from the Popup
surface and scroll, what happens?
You scroll up and down but with the Popup
opened ! And that's the problem.
The question is, how to detect from inside the Control, that some other unknown Parent Control in the VisualTree has started to scroll ?
and consecutively set IsDropDownOpen = false
?
Warning: This is a long comment, and it is basically just explaining my changes to @Rick Sladkey's response. It was a great starting point, but i did notice a few changes that I made with some things that I saw happening.
While doing my custom controls, I wanted something similar to this (I wanted to close a popup on a scroll), and found the answer to be something very similar to that of Rick Sladkey's answer with just a few minor changes to help improve some items.
The changes I made were mainly in regards to 3 items. The first being that i was seeing that the ScrollViewer_ScrollChanged
even was firing when i wasn't activly scrolling (other things set it off apparently). Next was that when I was unloading my controls, ScrollViewer_ScrollChanged
wasn't detached from the ScrollViewer
s, so if I added 3 and then removed 1 and scrolled, it would still fire 3 times instead of 2. Finally, I wanted to be able to add the functionality of allowing the consumer of my control to set the IsOpen property dynamically as well.
With that, my modified version of the ScrollTrigger
class looks something like:
public class ScrollTrigger : TriggerBase<FrameworkElement>
{
public bool TriggerOnNoChange
{
get
{
var val = GetValue(TriggerOnNoChangeProperty);
if (val is bool b)
{
return b;
}
return false;
}
set => SetValue(TriggerOnNoChangeProperty, value);
}
public static readonly DependencyProperty TriggerOnNoChangeProperty =
DependencyProperty.Register(
"TriggerOnNoChange",
typeof(bool),
typeof(ScrollTrigger),
new FrameworkPropertyMetadata(
false,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
protected override void OnAttached()
{
AssociatedObject.Loaded += AssociatedObject_Loaded;
AssociatedObject.Unloaded += AssociatedObject_Unloaded;
}
private void AssociatedObject_Loaded(
object sender,
RoutedEventArgs e)
{
foreach (var scrollViewer in GetScrollViewers())
scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged;
}
private void AssociatedObject_Unloaded(
object sender,
RoutedEventArgs e)
{
foreach (var scrollViewer in GetScrollViewers())
scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
}
private void ScrollViewer_ScrollChanged(
object sender,
ScrollChangedEventArgs e)
{
if(TriggerOnNoChange ||
Math.Abs(e.VerticalChange) > 0 ||
Math.Abs(e.HorizontalChange) > 0)
InvokeActions(e.OriginalSource);
}
private IEnumerable<ScrollViewer> GetScrollViewers()
{
for (DependencyObject element = AssociatedObject;
element != null;
element = VisualTreeHelper.GetParent(element))
if (element is ScrollViewer viewer) yield return viewer;
}
}
The first change here is that I added logic in ScrollViewer_ScrollChanged
to see if the offset values actually changed or not. I added a dependency property on the trigger to allow you to bypass that logic if you wish to. The second change that I added an unloaded event to the associated object, so that if the control was removed, it would remove the related actions to the ScrollViewers
, reducing the amount of times the ScrollViewer_ScrollChanged
call was made when adding and removing my controls dynamically.
With those changes in mind and the fact that I want to be able to allow consumers of my control to dictate how the popup is displayed, my .xaml looked something like:
<UserControl ...
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:tgrs="clr-namespace:NameSpace.To.ScrollTrigger.class.Namespace"
x:Name="MyControlNameRoot"
.../>
<i:Interaction.Triggers>
<tgrs:ScrollTrigger TriggerOnNoChange="False">
<i:InvokeCommandAction Command="{Binding ElementName=MyCommandNameRoot, Path=ClosePopupCommand}"/>
</tgrs:ScrollTrigger>
</i:Interaction.Triggers>
...
<Popup ...
IsOpen="{Binding ElementName=MyControlNameRoot, Path=IsPopupOpen, Mode=OneWay}"
.../>
...
</Popup>
...
</UserControl>
Now I needed something to bind to, and because i'm creating a custom control, I created some dependency properties and some other items in the code-behind. If you are using this approach with MVVM, you will want to write 'INotifyProperty
's and make sure your bindings are them (might not need the ElementName parts of the binding depending on how you do it). There are many ways to do that, and if you don't know, just google "mvvm data binding INotifyPropertyChanged" and you will easily find it out.
As a side note, I am also using Prism, so I am using DelegateCommand
s, but you can use whatever implementation of ICommand
you want. With that, my code-behind looked something like:
public partial class MyUserControl : UserControl
{
public MyUserControl()
{
ClosePopupCommand = new DelegateCommand(OnPopupCommand);
InitializeComponent();
}
...
public ICommand ClosePopupCommand { get; }
private OnClosePopupCommand ()
{
IsPopupOpen = false;
}
public static readonly DependencyProperty IsPopupOpenProperty =
DependencyProperty.Register(
"IsPopupOpen",
typeof(bool),
typeof(MyUserControl),
new FrameworkPropertyMetadata(
false,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public bool IsPopupOpen
{
get
{
var val = GetValue(IsPopupOpenProperty);
if (val is bool b)
{
return b;
}
return false;
}
set => SetValue(IsPopupOpenProperty, value);
}
...
}
And with that, I am able to have a popup that will close on any scroll trigger that actually has a change, doens't have any un-needed calls, and will also allow a user to modify whether is open or not.
If you have made it this far, thank you. I appreciate your dedication, and hopefully this helps out a little bit.
We can write a trigger for use with elements contained within a ScrollViewer
. Here is a complete sample application:
<Grid>
<ScrollViewer VerticalAlignment="Top" Height="200">
<StackPanel HorizontalAlignment="Left">
<Button Name="button" Content="Open">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click">
<ei:ChangePropertyAction TargetObject="{Binding ElementName=popup}" PropertyName="IsOpen" Value="True"/>
</i:EventTrigger>
<local:ScrollTrigger>
<ei:ChangePropertyAction TargetObject="{Binding ElementName=popup}" PropertyName="IsOpen" Value="False"/>
</local:ScrollTrigger>
</i:Interaction.Triggers>
</Button>
<Popup Name="popup" PlacementTarget="{Binding ElementName=button}">
<TextBlock Background="White" Text="Sample text"/>
</Popup>
<Rectangle Width="100" Height="100" Fill="Red"/>
<Rectangle Width="100" Height="100" Fill="Green"/>
<Rectangle Width="100" Height="100" Fill="Blue"/>
<Rectangle Width="100" Height="100" Fill="Yellow"/>
</StackPanel>
</ScrollViewer>
</Grid>
We have a button that opens a Popup
and any scrolling in any parent ScrollViewer
causes the ScrollTrigger
actions to fire and then we can close the popup. Note that the trigger is attached to the Button
and not the Popup
. We can use any nearby element that is in the visual tree. Also note that we use another trigger to open the Popup
but how it opens is not important to the original question.
Here is the ScrollTrigger
:
class ScrollTrigger : TriggerBase<FrameworkElement>
{
protected override void OnAttached()
{
AssociatedObject.Loaded += new RoutedEventHandler(AssociatedObject_Loaded);
}
void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
{
foreach (var scrollViewer in GetScrollViewers())
scrollViewer.ScrollChanged += new ScrollChangedEventHandler(scrollViewer_ScrollChanged);
}
void scrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
InvokeActions(e.OriginalSource);
}
IEnumerable<ScrollViewer> GetScrollViewers()
{
for (DependencyObject element = AssociatedObject; element != null; element = VisualTreeHelper.GetParent(element))
if (element is ScrollViewer) yield return element as ScrollViewer;
}
}
The ScrollTrigger
is very simple, it just attaches to all parent ScrollChanged
events and fires any contained actions. In the sample we use the ChangePropertyAction
to close the Popup
.
If you are not familiar with behaviors, install the Expression Blend 4 SDK and add these namespaces:
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
and add System.Windows.Interactivity
and Microsoft.Expression.Interactions
to your project.
I don't quite picture how your controls are, but can't you base your opening/closing of a control on the Focus event? And if it loses focus, to close the popup? Maybe I understand wrong, can you post a code snippet? Daniel
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With