使用 MVVM 从 WPF ListView 项触发双击事件

在使用 MVVM 的 WPF 应用程序中,我有一个带有 listview 项的用户控件。在运行时,它将使用数据绑定将对象集合填充列表视图。

将双击事件附加到列表视图中的项目的正确方法是什么,以便当双击列表视图中的项目时,视图模型中的相应事件将被激发,并有对单击的项目的引用?

如何用一种干净的 MVVM 方式完成,即在视图中没有代码落后?

90176 次浏览

I like to use Attached Command Behaviors and Commands. Marlon Grech has a very good implementation of the Attached Command Behaviors. Using these, we could then assign a style to the ListView's ItemContainerStyle property that will set the command for each ListViewItem.

Here we set the command to be fired on the MouseDoubleClick event, and the CommandParameter, will be the data object that we click on. Here I'm traveling up the visual tree to get the command that I'm using, but you could just as easily create application wide commands.

<Style x:Key="Local_OpenEntityStyle"
TargetType="{x:Type ListViewItem}">
<Setter Property="acb:CommandBehavior.Event"
Value="MouseDoubleClick" />
<Setter Property="acb:CommandBehavior.Command"
Value="{Binding ElementName=uiEntityListDisplay, Path=DataContext.OpenEntityCommand}" />
<Setter Property="acb:CommandBehavior.CommandParameter"
Value="{Binding}" />
</Style>

For the commands, you can either implement an ICommand directly, or use some of the helpers like those that come in the MVVM Toolkit.

You can use Caliburn's Action feature to map events to methods on your ViewModel. Assuming you have an ItemActivated method on your ViewModel, then corresponding XAML would look like:

<ListView x:Name="list"
Message.Attach="[Event MouseDoubleClick] = [Action ItemActivated(list.SelectedItem)]" >

For further details you can examine Caliburn's documentation and samples.

Please, code behind is not a bad thing at all. Unfortunately, quite a lot people in the WPF community got this wrong.

MVVM is not a pattern to eliminate the code behind. It is to separate the view part (appearance, animations, etc.) from the logic part (workflow). Furthermore, you are able to unit test the logic part.

I know enough scenarios where you have to write code behind because data binding is not a solution to everything. In your scenario I would handle the DoubleClick event in the code behind file and delegate this call to the ViewModel.

Sample applications that use code behind and still fulfill the MVVM separation can be found here:

Win Application Framework (WAF) - https://github.com/jbe2277/waf

I realize that this discussion is a year old, but with .NET 4, are there any thoughts on this solution? I absolutely agree that the point of MVVM is NOT to eliminate a code behind file. I also feel very strongly that just because something is complicated, doesn't mean it's better. Here is what I put in the code behind:

    private void ButtonClick(object sender, RoutedEventArgs e)
{
dynamic viewModel = DataContext;
viewModel.ButtonClick(sender, e);
}

I am finding it simpler to link the command when the view is created:

var r = new MyView();
r.MouseDoubleClick += (s, ev) => ViewModel.MyCommand.Execute(null);
BindAndShow(r, ViewModel);

In my case BindAndShow looks like this (updatecontrols+avalondock):

private void BindAndShow(DockableContent view, object viewModel)
{
view.DataContext = ForView.Wrap(viewModel);
view.ShowAsDocument(dockManager);
view.Focus();
}

Though the approach should work with whatever method you have of opening new views.

I have found a very easy and clean way to do this with the Blend SDK Event triggers. Clean MVVM, reusable and no code-behind.

You probably already have something like this:

<Style x:Key="MyListStyle" TargetType="{x:Type ListViewItem}">

Now include a ControlTemplate for the ListViewItem like this if you don't already use one:

<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListViewItem}">
<GridViewRowPresenter Content="{TemplateBinding Content}"
Columns="{TemplateBinding GridView.ColumnCollection}" />
</ControlTemplate>
</Setter.Value>
</Setter>

The GridViewRowPresenter will be the visual root of all elements "inside" making up a list row element. Now we could insert a trigger there to look for MouseDoubleClick routed events and call a command via InvokeCommandAction like this:

<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListViewItem}">
<GridViewRowPresenter Content="{TemplateBinding Content}"
Columns="{TemplateBinding GridView.ColumnCollection}">
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseDoubleClick">
<i:InvokeCommandAction Command="{Binding DoubleClickCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</GridViewRowPresenter>
</ControlTemplate>
</Setter.Value>
</Setter>

If you have visual elements "above" the GridRowPresenter (probalby starting with a grid) you can also put the Trigger there.

Unfortunately MouseDoubleClick events are not generated from every visual element (they are from Controls, but not from FrameworkElements for example). A workaround is to derive a class from EventTrigger and look for MouseButtonEventArgs with a ClickCount of 2. This effectively filters out all non-MouseButtonEvents and all MoseButtonEvents with a ClickCount != 2.

class DoubleClickEventTrigger : EventTrigger
{
protected override void OnEvent(EventArgs eventArgs)
{
var e = eventArgs as MouseButtonEventArgs;
if (e == null)
{
return;
}
if (e.ClickCount == 2)
{
base.OnEvent(eventArgs);
}
}
}

Now we can write this ('h' is the Namespace of the helper class above):

<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListViewItem}">
<GridViewRowPresenter Content="{TemplateBinding Content}"
Columns="{TemplateBinding GridView.ColumnCollection}">
<i:Interaction.Triggers>
<h:DoubleClickEventTrigger EventName="MouseDown">
<i:InvokeCommandAction Command="{Binding DoubleClickCommand}" />
</h:DoubleClickEventTrigger>
</i:Interaction.Triggers>
</GridViewRowPresenter>
</ControlTemplate>
</Setter.Value>
</Setter>

I am able to get this to work with .NET 4.5. Seems straight forward and no third party or code behind needed.

<ListView ItemsSource="{Binding Data}">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemTemplate>
<DataTemplate>
<Grid Margin="2">
<Grid.InputBindings>
<MouseBinding Gesture="LeftDoubleClick" Command="{Binding ShowDetailCommand}"/>
</Grid.InputBindings>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Image Source="..\images\48.png" Width="48" Height="48"/>
<TextBlock Grid.Row="1" Text="{Binding Name}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>

I saw the solution from rushui with the InuptBindings but I was still unable to hit the area of the ListViewItem where there was no text - even after setting the background to transparent, so I solved it by using different templates.

This template is for when the ListViewItem has been selected and is active:

<ControlTemplate x:Key="SelectedActiveTemplate" TargetType="{x:Type ListViewItem}">
<Border Background="LightBlue" HorizontalAlignment="Stretch">
<!-- Bind the double click to a command in the parent view model -->
<Border.InputBindings>
<MouseBinding Gesture="LeftDoubleClick"
Command="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, Path=DataContext.ItemSelectedCommand}"
CommandParameter="{Binding}" />
</Border.InputBindings>
<TextBlock Text="{Binding TextToShow}" />
</Border>
</ControlTemplate>

This template is for when the ListViewItem has been selected and is inactive:

<ControlTemplate x:Key="SelectedInactiveTemplate" TargetType="{x:Type ListViewItem}">
<Border Background="Lavender" HorizontalAlignment="Stretch">
<TextBlock Text="{Binding TextToShow}" />
</Border>
</ControlTemplate>

This is the Default style used for the ListViewItem:

<Style TargetType="{x:Type ListViewItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Border HorizontalAlignment="Stretch">
<TextBlock Text="{Binding TextToShow}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="Selector.IsSelectionActive" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Template" Value="{StaticResource SelectedActiveTemplate}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="Selector.IsSelectionActive" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Template" Value="{StaticResource SelectedInactiveTemplate}" />
</MultiTrigger>
</Style.Triggers>
</Style>

What I don't like is the repetition of the TextBlock and its text binding, I don't know I I can get around declaring that in just the one location.

I hope this helps someone!

Here's a behavior that gets that done on both ListBox and ListView.

public class ItemDoubleClickBehavior : Behavior<ListBox>
{
#region Properties
MouseButtonEventHandler Handler;
#endregion


#region Methods


protected override void OnAttached()
{
base.OnAttached();


AssociatedObject.PreviewMouseDoubleClick += Handler = (s, e) =>
{
e.Handled = true;
if (!(e.OriginalSource is DependencyObject source)) return;


ListBoxItem sourceItem = source is ListBoxItem ? (ListBoxItem)source :
source.FindParent<ListBoxItem>();


if (sourceItem == null) return;


foreach (var binding in AssociatedObject.InputBindings.OfType<MouseBinding>())
{
if (binding.MouseAction != MouseAction.LeftDoubleClick) continue;


ICommand command = binding.Command;
object parameter = binding.CommandParameter;


if (command.CanExecute(parameter))
command.Execute(parameter);
}
};
}


protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.PreviewMouseDoubleClick -= Handler;
}


#endregion
}

Here's the extension class used to find the parent.

public static class UIHelper
{
public static T FindParent<T>(this DependencyObject child, bool debug = false) where T : DependencyObject
{
DependencyObject parentObject = VisualTreeHelper.GetParent(child);


//we've reached the end of the tree
if (parentObject == null) return null;


//check if the parent matches the type we're looking for
if (parentObject is T parent)
return parent;
else
return FindParent<T>(parentObject);
}
}

Usage:

xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
xmlns:coreBehaviors="\{\{Your Behavior Namespace}}"




<ListView AllowDrop="True" ItemsSource="{Binding Data}">
<i:Interaction.Behaviors>
<coreBehaviors:ItemDoubleClickBehavior/>
</i:Interaction.Behaviors>


<ListBox.InputBindings>
<MouseBinding MouseAction="LeftDoubleClick" Command="{Binding YourCommand}"/>
</ListBox.InputBindings>
</ListView>

I succeed to make this functionality with .Net 4.7 framework by using the interactivity library, first of all make sure of declaring the namespace in the XAML file

xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"

Then set the Event Trigger with his respective InvokeCommandAction inside the ListView like below.

View:

<ListView x:Name="lv" IsSynchronizedWithCurrentItem="True"
ItemsSource="{Binding Path=AppsSource}"  >
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseDoubleClick">
<i:InvokeCommandAction CommandParameter="{Binding ElementName=lv, Path=SelectedItem}"
Command="{Binding OnOpenLinkCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" />
<GridViewColumn Header="Developed By" DisplayMemberBinding="{Binding DevelopedBy}" />
</GridView>
</ListView.View>
</ListView>

Adapting the code above should be enough to make the double click event work on your ViewModel, however I added you the Model and View Model class from my example so you can have the full idea.

Model:

public class ApplicationModel
{
public string Name { get; set; }


public string DevelopedBy { get; set; }
}

View Model:

public class AppListVM : BaseVM
{
public AppListVM()
{
_onOpenLinkCommand = new DelegateCommand(OnOpenLink);
_appsSource = new ObservableCollection<ApplicationModel>();
_appsSource.Add(new ApplicationModel("TEST", "Luis"));
_appsSource.Add(new ApplicationModel("PROD", "Laurent"));
}


private ObservableCollection<ApplicationModel> _appsSource = null;


public ObservableCollection<ApplicationModel> AppsSource
{
get => _appsSource;
set => SetProperty(ref _appsSource, value, nameof(AppsSource));
}


private readonly DelegateCommand _onOpenLinkCommand = null;


public ICommand OnOpenLinkCommand => _onOpenLinkCommand;


private void OnOpenLink(object commandParameter)
{
ApplicationModel app = commandParameter as ApplicationModel;


if (app != null)
{
//Your code here
}
}
}

In case you need the implementation of the DelegateCommand class.