DataTemplate 在 WPF 中传递不正确的命令参数

DataTemplate passing incorrect command parameter in WPF

我在 WPF 项目中有以下内容:

主要Window

<Window x:Class="DataTemplateEventTesting.Views.MainWindow"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        ...
        xmlns:vm="clr-namespace:DataTemplateEventTesting.ViewModels"
        xmlns:vw="clr-namespace:DataTemplateEventTesting.Views">
    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>
    <Grid>
        <Grid.ColumnDefinitions> ... </Grid.ColumnDefinitions>
        <ListView ItemsSource="{Binding SubViewModels}"
                  SelectedValue="{Binding MainContent, Mode=TwoWay}">
            <ListView.ItemTemplate>
                <DataTemplate DataType="{x:Type vm:SubViewModel}">
                    <TextBlock Text="{Binding DisplayText}" />
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <ContentControl Grid.Column="1" Content="{Binding MainContent}">
            <ContentControl.Resources>
                <DataTemplate x:Shared="False" DataType="{x:Type vm:SubViewModel}">
                    <vw:SubView />
                </DataTemplate>
            </ContentControl.Resources>
        </ContentControl>
    </Grid>
</Window>

SubView(SubViewModel 的视图)

<UserControl x:Class="DataTemplateEventTesting.Views.SubView"
             ...
             xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity">
    <Grid>
        <ListView ItemsSource="{Binding Models}">
            <ListView.View> ... </ListView.View>
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="SelectionChanged">
                    <i:InvokeCommandAction CommandParameter="{Binding RelativeSource={RelativeSource AncestorType={x:Type ListView}}}"
                                           Command="{Binding PrintCurrentItemsCommand}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </ListView>
    </Grid>
</UserControl>

问题出在 SubView 中的 SelectionChanged EventTrigger

PrintCurrentItemsCommand 接受一个 ListView 作为参数,并通过执行以下方法打印其项目的计数:

private void PrintCurrentItems(ListView listView)
{
    System.Diagnostics.Debug.WriteLine("{0}: {1} items.", DisplayText, listView.Items.Count);
}

当我从一个 SubView(其中 ListView 中的一些项目被选中)导航到另一个 SubView 时,SelectionChanged 事件在 [=第一个 SubView 的 18=]。这将在正确的 SubViewModel 上执行 PrintCurrentItemsCommand,但将新的(不正确的)ListView 作为参数传递。 (或者,或者事件被新 ListView 触发,并且命令使用旧 ListView 中的 DataContext。)

因此,虽然 SubViewModel 和 "Sub1" 的 DisplayText 在其 Models 集合中有 2 个项目,而 "Sub2" 有 3 个项目,我看到以下是输出 window:

Sub1: 2 items. // selected an item
Sub1: 3 items. // navigated to Sub2
Sub2: 3 items. // selected an item
Sub2: 2 items. // navigated to Sub1
Sub1: 2 items. // selected an item
Sub1: 3 items. // navigated to Sub2
Sub2: 3 items. // selected an item
Sub2: 2 items. // navigated to Sub1

显然,预期的行为是传递正确的 ListView

主要的混淆是,例如,"Sub1" 的命令能够访问 "Sub2" 的 ListView

我阅读了一些关于 WPF caching templates 的内容,并认为我已经找到了在 DataTemplate 上设置 x:Shared = "False" 的解决方案,但这并没有改变任何东西。

对这种行为有解释吗?有解决办法吗?

我能够重现您所看到的行为:我 select 右侧列表视图中的一个项目,然后更改 select 左侧列表视图中的项目。调用命令时,在 Execute 方法内部,! Object.ReferenceEquals(this, listView.DataContext)。我本以为他们是平等的。

使用 Command 的绑定,它们仍然不相等:

<i:InvokeCommandAction 
    Command="{Binding DataContext.PrintCurrentItemsCommand, RelativeSource={RelativeSource AncestorType={x:Type ListView}}}" 
    CommandParameter="{Binding RelativeSource={RelativeSource AncestorType={x:Type ListView}}}" 
    />

我对那个实验没有太大期望,但尝试并没有花很长时间。

遗憾的是,我现在没有时间深入研究这个问题。我没能找到 System.Windows.Interactivity.InvokeCommandAction 的源代码,但它确实看起来好像在伴随更改的一系列事件和更新中的某个地方,事情发生的顺序错误。

分辨率

下面的代码丑得几乎无法忍受,但它的行为符合预期。您可以通过编写自己的行为来使其不那么丑陋。它不需要像 InvokeCommandAction 那样广义概括。不太普遍,它不太可能以同样的方式出现错误行为,即使出现这种情况,您也有源代码并可以正确调试它。

SubView.xaml

<ListView 
    ItemsSource="{Binding Models}"
    SelectionChanged="ListView_SelectionChanged"
    >
    <!-- snip -->

SubView.xaml.ds

private void ListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var listView = sender as ListView;
    var cmd = listView.DataContext?.GetType().GetProperty("PrintCurrentItemsCommand")?.
                  GetValue(listView.DataContext) as ICommand;

    if (cmd?.CanExecute(listView) ?? false)
    {
        cmd.Execute(listView);
    }
}

稍微跑题了,最好这样:

    protected void PrintCurrentItems(System.Collections.IEnumerable items)
    {
        //...

XAML

<i:InvokeCommandAction 
    Command="{Binding PrintCurrentItemsCommand}" 
    CommandParameter="{Binding Items, RelativeSource={RelativeSource AncestorType={x:Type ListView}}}" 
    />

代码隐藏

    if (cmd?.CanExecute(listView) ?? false)
    {
        cmd.Execute(listView.Items);
    }

原因是将 IEnumerable 作为参数的命令比期望将任何项目集合打包在列表视图中的命令更为普遍。从列表视图中获取项目集合很容易;在向某人传递项目集合之前需要有一个列表视图真的很痛苦。在不搬起石头砸自己脚的情况下,始终接受最不具体的参数。

从 MVVM 的角度来看,视图模型拥有 UI 的任何具体知识被认为是非常糟糕的做法。如果 UI 设计团队后来决定应该使用 DataGrid 或 ListBox 而不是 ListView 怎么办?如果他们超过了您 Items,那完全不是问题。如果他们通过你 ListView,他们必须给你发一封电子邮件,要求你更改参数类型,然后就此与你进行协调,然后进行额外的测试等。所有这些都是为了适应一个没有的参数实际上根本不需要 ListView

原来是DataTemplate.

的持久化导致的问题

正如 Ed Plunkett 观察到的那样,ListView 一直都是一样的,只有 DataContext 在变化。我想发生的事情是导航发生了,然后事件被触发了,这时候 DataContext 已经改变了——一个简单的 属性 改变。

在希望的行为中,旧的 ListView 将触发事件,并执行第一个 ViewModel 的命令,这将在导航之后发生,因此,它的项目将被计为 0。但是随着DataTemplate分享,第一个ListView,第二个ListView,所以它的items不计为0,已经被items替换了来自第二个 ViewModel。这发生在导航之后,因此预计 RelativeSource 将 return ListView 与第二个 ViewModel 作为其 DataContext

我已经设法通过使用自定义 DataTemplateSelector class:

来覆盖此默认行为
public class ViewSelector : DataTemplateSelector
{
    public override DataTemplate SelectTemplate(object item, DependencyObject container)
    {
        if (container is FrameworkElement element && item is SubViewModel)
        {
            return element.FindResource("subviewmodel_template") as DataTemplate;
        }

        return null;
    }
}

DataTemplate存储在一个ResourceDictionary中(合并在App.xaml中):

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:DataTemplateEventTesting.Views"
                    xmlns:vm="clr-namespace:DataTemplateEventTesting.ViewModels">
    <DataTemplate x:Shared="False" x:Key="subviewmodel_template" DataType="{x:Type vm:SubViewModel}">
        <local:SubView />
    </DataTemplate>
</ResourceDictionary>

原来在ResourceDictionary中,x:Shared="False"有我想要的临界效果(显然这个只在ResourceDictionary中有效) - 它使每个 ViewModel 的模板保持隔离。

主要Window现在写成:

<Window x:Class="DataTemplateEventTesting.Views.MainWindow"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        ...
        xmlns:vm="clr-namespace:DataTemplateEventTesting.ViewModels"
        xmlns:vw="clr-namespace:DataTemplateEventTesting.Views">
    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>
    <Window.Resources>
        <vw:ViewSelector x:Key="view_selector" />
    </Window.Resources>
    <Grid>
        <Grid.ColumnDefinitions> ... </Grid.ColumnDefinitions>
        <ListView ItemsSource="{Binding SubViewModels}"
                  SelectedValue="{Binding MainContent, Mode=TwoWay}">
            <ListView.ItemTemplate>
                <DataTemplate DataType="{x:Type vm:SubViewModel}">
                    <TextBlock Text="{Binding DisplayText}" />
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <ContentControl Grid.Column="1" Content="{Binding MainContent}"
                        ContentTemplateSelector="{StaticResource view_selector}" />
    </Grid>
</Window>

有趣的是,我发现在这个特定的例子中需要满足以下两个条件:

一个

DataTemplatex:Shared="False"ResourceDictionary 中。

两个

使用了DataTemplateSelector

例如,当我满足第一个条件,使用<ContentControl ... ContentTemplate="{StaticResource subviewmodel_template}" />,问题就解决了

同样,当x:Shared="False"不存在时,DataTemplateSelector不再有效。

满足这两个条件后,输出 window 显示:

Sub1: 2 items. // selected an item
Sub1: 0 items. // navigated to Sub2
Sub2: 3 items. // selected an item
Sub2: 0 items. // navigated to Sub1
Sub1: 2 items. // selected an item
Sub1: 0 items. // navigated to Sub2
Sub2: 3 items. // selected an item
Sub2: 0 items. // navigated to Sub1

这是我之前在不同类型的 ViewModel 之间切换时观察到的预期行为。

为什么选择 DataTemplateSelector?

阅读 documentation for x:Shared 后,我至少有一个理论可以解释为什么 DataTemplateSelector 似乎需要这个才能工作。

如该文档所述:

In WPF, the default x:Shared condition for resources is true. This condition means that any given resource request always returns the same instance.

这里的关键词是请求

在不使用 DataTemplateSelector 的情况下,WPF 可以确定它需要使用的资源。因此,它只需要获取一次 - 一个 request.

对于 DataTemplateSelector,没有确定性,因为即使对于相同类型的 ViewModel,DataTemplateSelector 中也可能存在更多逻辑。因此,DataTemplateSelector 强制对 Content 中的每个更改进行 请求 ,而对于 x:Shared="False"ResourceDictionary 将始终 return一个新实例。