带有 MVVM 的自定义控件的 WPF 复杂逻辑

WPF Complex Logic of Custom Controls with MVVM

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

WPF Window 由 2 个选项卡组成,每个 Tab 都是我通过 [=17= 插入到 TabItem 中的自定义 UserControl ]. Main Window 有一个 ViewModel 绑定数据的地方。

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

MainWindow.xaml

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

LevelsTab.xaml 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<Level>
Level.cs

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

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

但在这一点上我很迷茫:

我希望我能正确解释情况,尽管它非常复杂,感谢您的帮助!

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

假设您在某处将多转换器设置为静态资源,则显示该值的 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 事件添加一个侦听器以触发多重绑定转换器具有 'changed'。总的来说,我的 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;
}

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

如果您打算让 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.

为了更健壮的实施,您应该订阅关卡项目的 CollectionChanged event of the Levels collection, so can subscribe to and unsubscribe from the 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;
      }
   }
}

级别选项卡项目的视图可能如下所示。您可以将 ListBoxLevelDefinition 用户控件一起用作项目模板来显示级别。或者,您可以为 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>

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