【问题标题】:DataTemplate passing incorrect command parameter in WPFDataTemplate 在 WPF 中传递不正确的命令参数
【发布时间】:2017-12-11 18:24:05
【问题描述】:

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

主窗口

<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 事件在第一个SubViewListView 上触发。这将在正确的SubViewModel 上执行PrintCurrentItemsCommand,但将新的(不正确的)ListView 作为参数传递。 (要么,要么事件被新的ListView 触发,并且命令使用旧的ListView 中的DataContext。)

因此,虽然“Sub1”的 SubViewModelDisplayText 在其 Models 集合中有 2 个项目,而“Sub2”有 3 个项目,但我在“输出”窗口中看到以下内容:

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" 找到了解决方案,但这并没有改变任何东西。

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

【问题讨论】:

  • “这会在正确的 SubViewModel 上执行 PrintCurrentItemsCommand” -- 你能详细说明你是如何确认的吗?
  • 这是基于为该 ViewModel 打印 DisplayText 的方法。我想可能是相反的情况,新的ListView 触发了事件,而命令使用的是旧ListView 中的DataContext。我会将其添加到问题中。但无论哪种方式,在我看来,“输出”窗口都在确认存在错位。

标签: c# wpf xaml mvvm datatemplate


【解决方案1】:

我能够重现您所看到的行为:我在右侧列表视图中选择了一个项目,然后在左侧列表视图中更改了选择。调用命令时,在 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

【讨论】:

  • 关于题外话,我可能应该在我的问题中提到传递ListView 只是为了测试,实际上我将传递选定的项目集合。我同意你不要将 UI 元素传递给 ViewModel - 尽管我可能也应该在我的测试中实践这一点!
  • 感谢您的解决方案 - 您是对的,它很丑,但它有效。这也证实了我最初的假设是不正确的——触发事件的是新的ListView
  • 同一个ListView。 DataContext 类型不会改变,因此它不会再次实例化模板。我在 ListView 上处理了 DataContextChanged 事件,当左 LV 选择发生变化时它被引发一次。 OldValue 是之前选择的左 LV 项,NewValue 是新选择的一项。所以,同样的 ListView。
  • 啊,这就是主要问题了。有什么方法可以强制DataTemplate 更改/刷新(我将研究DataTemplateSelector 以尝试这样做)?实际上,我的项目中还需要做其他事情,例如,从每个 SubViewModel 传递和渲染显示设置。
  • 我怀疑 DataTemplateSelector 会有什么不同,但您不妨尝试一下。从长期的 WPF 经验来看,我建议不要采取任何行动来试图迫使事件和绑定更新的神秘错误排序。事实上,我建议不要在这个问题上浪费更多时间,直到您确切知道 什么 出了问题。在最初的几分钟之后,猜测它是一种非生产性的时间沉没。事件处理不是罪过;他们将它保存在 WPF 中是有原因的。如果处理事件的迂回间接方式失败,但直接方式有效......好吧,直接做。
【解决方案2】:

原来问题是DataTemplate的持久化引起的。

正如 Ed Plunkett 所观察到的,ListView 一直是相同的,只有 DataContext 发生了变化。我想发生的事情是发生了导航,然后触发了事件,此时 DataContext 已更改 - 一个简单的属性更改。

在希望的行为中,旧的ListView 会触发事件,并执行第一个 ViewModel 的命令,这将发生在导航之后,因此,它的项目将被计数为 0。但是 DataTemplate 共享,第一个 ListView 第二个 ListView,所以它的项目不计为 0,它们已被第二个 ViewModel 中的项目替换。这发生在导航之后,因此预计RelativeSource 将返回ListView,第二个ViewModel 作为其DataContext

我已设法通过使用自定义 DataTemplateSelector 类来覆盖此默认行为:

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 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>

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

一个

DataTemplateResourceDictionaryx:Shared="False" 中。

两个

使用DataTemplateSelector

例如,当我满足第一个条件并使用&lt;ContentControl ... ContentTemplate="{StaticResource subviewmodel_template}" /&gt;时,问题就出现了。

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

一旦满足这两个条件,输出窗口就会显示:

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 才能使其工作有了一个理论。

如该文档所述:

在 WPF 中,资源的默认x:Shared 条件是true。这种情况意味着任何给定的资源请求总是返回相同的实例。

这里的关键词是request

不使用DataTemplateSelector,WPF 可以确定它需要使用哪个资源。因此,它只需要获取一次 - 一个 request

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-12-29
    • 2023-03-19
    • 1970-01-01
    • 1970-01-01
    • 2019-10-18
    • 1970-01-01
    • 2017-10-21
    相关资源
    最近更新 更多