【问题标题】:WPF Complex Logic of Custom Controls with MVVM带有 MVVM 的自定义控件的 WPF 复杂逻辑
【发布时间】:2021-02-28 07:20:38
【问题描述】:

我正在创建一个基于 WPF 的插件(用于 Revit,一种建筑 3D 建模软件,但这不重要),它非常复杂,我有点迷路了。

WPF Window 由 2 个选项卡组成,每个 Tab 是我通过 Frame 插入到 TabItem 的自定义 UserControl。主窗口有一个ViewModel 绑定数据。

其中一个选项卡有助于在 3D 模型中创建地板

MainWindow.xaml 的一部分

<TabItem Name="LevelsTab" Header="Levels" HorizontalContentAlignment="Left">
    <ScrollViewer >
        <Frame Name="LevelsContent" Source="LevelsTab.xaml"/>
    </ScrollViewer>
</TabItem>

LevelsTab.xamlUserControl 是真正的准系统,只包含用于创建或删除自定义 UserControl 的按钮,我创建该控件用于在 UI 中以图形方式表示楼层(下面的屏幕截图)。这也很简单:
LevelDefinition.xaml

<UserControl x:Class="RevitPrototype.Setup.LevelDefinition" ....
    <Label Grid.Column="0" Content="Level:"/>
    <TextBox Name="LevelName" Text={Binding <!--yet to be bound-->}/>
    <TextBox Name="LevelElevation"  Text={Binding <!--yet to be bound-->}/>
    <TextBox Name="ToFloorAbove" Text={Binding <!--yet to be bound-->}/>
</UserControl>

当用户在 LevelsTab.xaml 中单击添加或删除楼层的按钮时,会在网格中添加或删除一个新的LevelDefinition

每个LevelDefinition 都可以使用MVVM 从不同TextBox 元素中包含的信息创建一个Level 对象。最终,在 ViewModel 中,我猜应该有一个 List&lt;Level&gt;
Level.cs

class Level
{
    public double Elevation { get; set; }
    public string Name { get; set; }
    public string Number { get; set; }
}

虽然每个LevelDefinition 都应该绑定到前一个,因为下面的楼层包含到上面楼层的高度信息。 LevelDefinition.xaml 中最右边的TextBox 表示当前楼层与上方楼层之间的距离,因此 Height `TextBox 应该只是其高度的总和加上到上一层的距离:
当然这里的额外难度是,如果我在一层中将距离更改为上一层,则上面的所有楼层都必须更新高度。例如:我将 LEVEL 01(从图片)更改为 4 米到上面的水平,LEVEL 02 的高度必须更新为 7m(而不是 6),LEVEL 03 的高度必须变为 10m。

但此时我很迷茫:

  • 如何获得将楼层高度绑定到下面楼层信息的逻辑?
  • 在这种情况下如何正确实现 MVVM?

我希望我设法正确解释了情况,尽管情况很复杂,感谢您的帮助!

【问题讨论】:

  • 要连接项目,您需要使用 LinkedList,但我会在您的 LevelDefinition 上实现 Parent 和 child 属性,然后添加孩子的地板高度。如果您希望您的窗口使用 Label 执行停止以仅显示文本,请改用 TextBlock。你为什么使用框架?此外,所有这些名称都是无关紧要的,除非您使用动画但没有迹象表明您这样做。

标签: c# wpf xaml mvvm revit-api


【解决方案1】:

如果您打算使您的Level 项目可编辑,您必须实现INotifyPropertyChanged。我为演示目的创建了一个关卡视图模型,并添加了一个属性OverallElevation,它表示当前的标高,包括以前的标高。

public class LevelViewModel : INotifyPropertyChanged
   {
      private string _name;
      private int _number;
      private double _elevation;
      private double _overallElevation;

      public LevelViewModel(string name, int number, double elevation, double overallElevation)
      {
         Number = number;
         Name = name;
         Elevation = elevation;
         OverallElevation = overallElevation;
      }

      public string Name
      {
         get => _name;
         set
         {
            if (_name == value)
               return;

            _name = value;
            OnPropertyChanged();
         }
      }

      public int Number
      {
         get => _number;
         set
         {
            if (_number == value)
               return;

            _number = value;
            OnPropertyChanged();
         }
      }

      public double Elevation
      {
         get => _elevation;
         set
         {
            if (_elevation.CompareTo(value) == 0)
               return;

            _elevation = value;
            OnPropertyChanged();
         }
      }

      public double OverallElevation
      {
         get => _overallElevation;
         set
         {
            if (_overallElevation.CompareTo(value) == 0)
               return;

            _overallElevation = value;
            OnPropertyChanged();
         }
      }

      public event PropertyChangedEventHandler PropertyChanged;

      protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
      {
         PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
      }
   }

您可以将这些属性绑定到您的LevelDefinition 用户控件。我改编了你的样本,因为它不完整。由于计算了整体高程,因此我将相应的TextBox 设置为只读,但您应该真正使用TextBlock 或类似的只读控件。

<UserControl x:Class="RevitPrototype.Setup.LevelDefinition"
             ...>
   <UserControl.Resources>
      <Style TargetType="{x:Type TextBox}" BasedOn="{StaticResource {x:Type TextBox}}">
         <Setter Property="Margin" Value="5"/>
      </Style>
   </UserControl.Resources>
   <Grid>
      <Grid.ColumnDefinitions>
         <ColumnDefinition/>
         <ColumnDefinition/>
         <ColumnDefinition/>
         <ColumnDefinition/>
      </Grid.ColumnDefinitions>
      <Label Grid.Column="0" Content="Level:"/>
      <TextBox Grid.Column="1" Name="LevelName" Text="{Binding Name}"/>
      <TextBox Grid.Column="2" Name="LevelElevation"  Text="{Binding OverallElevation}" IsReadOnly="True"/>
      <TextBox Grid.Column="3" Name="ToFloorAbove" Text="{Binding Elevation}"/>
   </Grid>
</UserControl>

由于您没有提供您的选项卡视图模型,我创建了一个以供参考。此视图模型公开了一个ObservableCollection 级别、一个GroundFloor 属性以及添加和删除级别的命令。我使用DelegateCommand 类型,但您可以使用其他类型。

每次添加关卡时,您都会订阅新关卡的PropertyChanged 事件,而在移除关卡时,您会取消订阅以防止内存泄漏。现在,每当LevelViewModel 实例上的属性发生更改时,都会调用OnLevelPropertyChanged 方法。此方法检查 Elevation 属性是否已更改。如果是,则调用UpdateOverallElevation 方法,该方法重新计算所有整体高程属性。当然,您可以对此进行优化,仅重新计算高于当前作为sender 传递的级别。

为了更健壮的实现,你应该订阅Levels集合的CollectionChanged event,这样你就可以订阅和取消订阅关卡项目的PropertyChanged事件,只要你添加、删除或修改其他集合方式而不是通过诸如恢复持久集合之类的命令。

public class LevelsViewModel
{
   private const string GroundName = "GROUND FLOOR";
   private const string LevelName = "LEVEL";

   public ObservableCollection<LevelViewModel> Levels { get; }

   public LevelViewModel GroundFloor { get; }

   public ICommand Add { get; }

   public ICommand Remove { get; }

   public LevelsViewModel()
   {
      Levels = new ObservableCollection<LevelViewModel>();
      GroundFloor = new LevelViewModel(GroundName, 0, 0, 0);
      Add = new DelegateCommand<string>(ExecuteAdd);
      Remove = new DelegateCommand(ExecuteRemove);

      GroundFloor.PropertyChanged += OnLevelPropertyChanged;
   }

   private void ExecuteAdd(string arg)
   {
      if (!double.TryParse(arg, out var value))
         return;

      var lastLevel = Levels.Any() ? Levels.Last() : GroundFloor;

      var number = lastLevel.Number + 1;
      var name = GetDefaultLevelName(number);
      var overallHeight = lastLevel.OverallElevation + value;
      var level = new LevelViewModel(name, number, value, overallHeight);

      level.PropertyChanged += OnLevelPropertyChanged;
      Levels.Add(level);
   }

   private void ExecuteRemove()
   {
      if (!Levels.Any())
         return;

      var lastLevel = Levels.Last();
      lastLevel.PropertyChanged -= OnLevelPropertyChanged;
      Levels.Remove(lastLevel);
   }

   private void OnLevelPropertyChanged(object sender, PropertyChangedEventArgs e)
   {
      if (e.PropertyName != nameof(LevelViewModel.Elevation))
         return;

      UpdateOverallElevation();
   }

   private static string GetDefaultLevelName(int number)
   {
      return $"{LevelName} {number:D2}";
   }

   private void UpdateOverallElevation()
   {
      GroundFloor.OverallElevation = GroundFloor.Elevation;
      var previousLevel = GroundFloor;

      foreach (var level in Levels)
      {
         level.OverallElevation = previousLevel.OverallElevation + level.Elevation;
         previousLevel = level;
      }
   }
}

级别选项卡项目的视图可能如下所示。您可以使用带有LevelDefinition 用户控件的ListBox 作为项目模板来显示级别。或者,您可以为LevelViewModel 的每个属性使用带有可编辑列的DataGrid,这对用户来说会更加灵活。

<Grid>
   <Grid.RowDefinitions>
      <RowDefinition/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="Auto"/>
   </Grid.RowDefinitions>
   <ListView ItemsSource="{Binding Levels}">
      <ListBox.ItemContainerStyle>
         <Style TargetType="{x:Type ListBoxItem}">
            <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
         </Style>
      </ListBox.ItemContainerStyle>
      <ListBox.ItemTemplate>
         <DataTemplate>
            <local:LevelDefinition/>
         </DataTemplate>
      </ListBox.ItemTemplate>
   </ListView>
   <DockPanel Grid.Row="1" Margin="5">
      <Button DockPanel.Dock="Right" Content="-" MinWidth="50" Command="{Binding Remove}"/>
      <Button DockPanel.Dock="Right" Content="+" MinWidth="50" Command="{Binding Add}" CommandParameter="{Binding Text, ElementName=NewLevelElevationTextBox}"/>
      <TextBox x:Name="NewLevelElevationTextBox" MinWidth="100"/>
   </DockPanel>
   <local:LevelDefinition Grid.Row="2" DataContext="{Binding GroundFloor}"/>
</Grid>

这是一个简化的例子,没有输入验证,添加时忽略无效值。

【讨论】:

  • 哇谢谢@thatguy!这是非常令人印象深刻的! LevelViewModel 是我帖子中 Level 类的补充,还是取而代之?另外,我应该每个选项卡一个 ViewModel,还是包含两个选项卡的 MainWindow 一个 ViewModel?
  • @AndreaTassera 我的情况是我创建了它。然而,有不同的方法,你可以在你的模型中实现INotifyPropertyChanged,你可以创建一个单独的Level模型和一个LevelViewModel类型并每次复制数据或者你创建一个视图模型类型包装 您的模型,这意味着您在视图模型中传递模型实例并将其存储在那里,并调整属性的 getter 和 setter 以在内部对模型起作用。对于选项卡的视图模型,最好为每个视图或选项卡创建单独的视图模型,这也有利于使用数据模板。
  • @AndreaTassera 更多数据模板信息,可以参考overview on MSDN
  • 感谢您提供信息和解决方案。我根据自己的需要调整了一些东西,现在效果很好!
  • @AndreaTassera 有各种来源,例如MVVM: To Wrap or Not to Wrap? How much should the ViewModel wrap the Model? 。我想你也会发现其他与创建和包装视图模型相关的问题。
【解决方案2】:

我已经设法使用多绑定转换器实现了这一点。

假设您在某处将多转换器设置为静态资源,则显示该值的 TextBlock 为:

<TextBlock>
    <TextBlock.Text>
        <MultiBinding Converter="{StaticResource ElevationMultiConverter}">
            <MultiBinding.Bindings>
                <Binding Path="" />
                <Binding Path="DataContext.Levels" RelativeSource="{RelativeSource AncestorType={x:Type ItemsControl}}" />
            </MultiBinding.Bindings>
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

转换器本身如下所示:

class ElevationMultiConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        var item = values[0] as Level;
        var list = values[1] as IList<Level>;
        var lowerLevels = list.Where(listItem => list.IndexOf(listItem) <= list.IndexOf(item));
        var elevation = lowerLevels.Sum(listItem => listItem.Height);
        return elevation.ToString();
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

在此示例中,取决于列表中项目的特定顺序来确定一个级别是高于还是低于另一个级别;你可以使用一个属性,或者其他任何东西。

我没有为这个示例使用框架,所以我需要自己在任何地方实现 INotifyPropertyChanged。在 MainViewModel 中,这意味着向每个 Level 元素的 PropertyChanged 事件添加一个侦听器,以触发多重绑定转换器“更改”。总的来说,我的 MainViewModel 看起来像这样:

class MainViewModel :INotifyPropertyChanged
{
    public ObservableCollection<Level> Levels { get; set; }

    public MainViewModel()
    {
        Levels = new ObservableCollection<Level>();
        Levels.CollectionChanged += Levels_CollectionChanged;
    }

    private void Levels_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        foreach(var i in e.NewItems)
        {
            (i as Level).PropertyChanged += MainViewModel_PropertyChanged;
        }
    }

    private void MainViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Levels)));
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

它是如何工作的: 一个新的 Level 被添加到集合中,它的 PropertyChanged 事件被包含的视图模型监听。当关卡的高度发生变化时,PropertyChanged 事件被触发并被 MainViewModel 拾取。反过来,它会触发 Levels 属性的 PropertyChanged 事件。 MultiConverter 绑定到 Levels 属性,并且它的所有更改都会触发转换器重新评估和更新所有级别组合高度值。

【讨论】:

  • 谢谢皮特!它看起来非常简洁和优雅!我将对这些主题进行一些研究,因为我对 IMultiValueConverter 不是很熟悉
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-03-27
  • 1970-01-01
  • 2020-02-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多