简体   繁体   English

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

[英]WPF Complex Logic of Custom Controls with MVVM

I am creating a WPF-based plugin (for Revit, an architectural 3D modelling software, but this shouldn't matter) which is quite complex and I'm getting kind of lost.我正在创建一个基于 WPF 的插件(用于 Revit,一个建筑 3D 建模软件,但这应该无关紧要)它非常复杂,我有点迷路了。

The WPF Window is composed by 2 tabs and each Tab is a custom UserControl that I'm inserting in the TabItem through a Frame . WPF Window由 2 个选项卡组成,每个Tab都是我通过Frame插入TabItem的自定义UserControl The Main Window has a ViewModel where the data is bound.主窗口有一个绑定数据的ViewModel

One of the tabs helps with the creation of floors in a 3D model其中一个选项卡有助于在 3D 模型中创建地板

part of MainWindow.xaml MainWindow.xaml 的一部分

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

The LevelsTab.xaml UserControl is really barebone and just contains buttons to create or remove a custom UserControl I created to represent graphically a floor in the UI (screenshot below). LevelsTab.xaml UserControl是真正的准系统,只包含用于创建或删除自定义 UserControl 的按钮,我创建的自定义 UserControl 以图形方式表示 UI 中的楼层(下面的屏幕截图)。 This very simple as well:这也非常简单:
LevelDefinition.xaml关卡定义.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>

When the user clicks the buttons to add or remove floors in LevelsTab.xaml , a new LevelDefinition is added or removed to the gird.当用户单击LevelsTab.xaml 中的按钮以添加或删除楼层时,新的LevelDefinition将添加或删除到网格中。

Each LevelDefinition will be able to create a Level object from the information contained in the different TextBox elements, using MVVM.每个LevelDefinition都能够使用 MVVM 从不同TextBox元素中包含的信息创建一个Level对象。 Eventually, in the ViewModel, I should have a List<Level> I guess.最终,在 ViewModel 中,我想我应该有一个List<Level>
Level.cs级别.cs

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

Each LevelDefinition should be sort of bound to the previous one though, as the floor below contains the information of the height to the Level above.但是,每个LevelDefinition都应该绑定到前一个,因为下面的地板包含到上面的 Level 的高度信息。 The right-most TextBox in LevelDefinition.xaml indicated the distance between the current floor and the floor above, hence the Height `TextBox should just be the sum of its height PLUS the distance to the level above: LevelDefinition.xaml 中最右边的TextBox表示当前楼层和上面楼层之间的距离,因此高度`TextBox 应该只是其高度的总和加上到上面楼层的距离: 用户界面
Of course the extra level of difficulty here is that if I change distance to the level above in one floor, all the floors above will have to update the height.当然,这里额外的难度是,如果我在一层中将距离更改为上一层,那么上层的所有楼层都必须更新高度。 For example: I change LEVEL 01 (from the pic) to have 4 meters to the level above, LEVEL 02's height will have to update to become 7m (instead of 6) and LEVEL 03's will have to become 10m.例如:我将 LEVEL 01(从图片中)改为 4 米到上面的水平,LEVEL 02 的高度必须更新为 7m(而不是 6),LEVEL 03 的高度必须为 10m。

But at this point I'm very lost:但在这一点上我很失落:

  • How do I get this logic of getting the floor height bound to the info in the floor below?我如何获得将地板高度绑定到下面地板中的信息的逻辑?
  • How do I implement MVVM correctly in this case?在这种情况下如何正确实现 MVVM?

I hope I managed to explain the situation correctly even though it's quite complex and thanks for the help!我希望我设法正确解释了情况,即使它非常复杂,感谢您的帮助!

If you intend to make your Level items editable, you have to implement INotifyPropertyChanged .如果您打算使您的Level项目可编辑,则必须实现INotifyPropertyChanged I created a level view model for demonstration purposes and added a property OverallElevation that represents the current elevation including that of previous levels.我创建了一个用于演示目的的关卡视图模型,并添加了一个属性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));
      }
   }

You can bind these properties to your LevelDefinition user control.您可以将这些属性绑定到LevelDefinition用户控件。 I adapted your sample, because it is incomplete.我改编了你的样本,因为它不完整。 Since the overall elevation is calculated, I set the corresponding TextBox to be read-only, but you should really use a TextBlock or a similar read-only control instead.由于计算了整体高程,因此我将相应的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>

Since you did not provide your tab view model, I created one for reference.由于您没有提供选项卡视图模型,我创建了一个供参考。 This view model exposes an ObservableCollection of levels, a GroundFloor property and commands to add and remove levels.此视图模型公开了一个ObservableCollection级别、一个GroundFloor属性以及用于添加和删除级别的命令。 I use a DelegateCommand type, but you may use a different one.我使用DelegateCommand类型,但您可以使用不同的类型。

On each add of a level, you subscribe to the PropertyChanged event of the new level and on removal you unsubscribe to prevent memory leaks.在每次添加级别时,您订阅新级别的PropertyChanged事件,并在删除时取消订阅以防止内存泄漏。 Now, whenever a property changes on a LevelViewModel instance, the OnLevelPropertyChanged method is called.现在,每当LevelViewModel实例上的属性发生更改时,都会调用OnLevelPropertyChanged方法。 This method checks, if the Elevation property was changed.此方法检查Elevation属性是否已更改。 If it was, the UpdateOverallElevation method is called, which recalculates all overall elevation properties.如果是,则调用UpdateOverallElevation方法,该方法重新计算所有整体高程属性。 Of course you could optimize this to only recalculate the levels above the current one passed as sender .当然,您可以优化它以仅重新计算作为sender传递的当前级别之上的级别。

For a more robust implementation, you should subscribe to the CollectionChanged event of the Levels collection, so can subscribe to and unsubscribe from the PropertyChanged events of level items whenever you add, remove or modify the collection in other ways than through the commands like restoring a persisted collection.为了获得更健壮的实现,您应该订阅Levels集合的CollectionChanged事件,这样每当您以其他方式添加、删除或修改集合时都可以订阅和取消订阅关卡项目的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;
      }
   }
}

The view for the levels tab item could look like below.级别选项卡项的视图可能如下所示。 You can use a ListBox with your LevelDefinition user control as item template to display the levels.您可以将ListBoxLevelDefinition用户控件一起用作项目模板来显示级别。 Alternatively, you could use a DataGrid with editable columns for each property of the LevelViewModel , which would be more flexible for users.或者,您可以为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>

This is a simplified example, there is no input validation, invalid values are ignored on adding.这是一个简化示例,没有输入验证,添加时会忽略无效值。

I've managed to implement this using a multi-binding converter.我已经设法使用多绑定转换器来实现这一点。

Assuming that you set up the multi-converter as a static resource somewhere, the TextBlock to display the value is:假设您在某处将多转换器设置为静态资源,则显示值的 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>

The converter itself looks like this:转换器本身看起来像这样:

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();
    }
}

In this example, it depends on the specific order of items in the list to determine whether a level is above or below another;在这个例子中,它取决于列表中项目的特定顺序来确定一个级别是高于还是低于另一个级别; you could use a property, or whatever else.你可以使用一个属性,或者其他任何东西。

I didn't use a framework for this example so I needed to implement INotifyPropertyChanged everywhere myself.我没有在这个例子中使用框架,所以我需要自己在任何地方实现 INotifyPropertyChanged。 In the MainViewModel, this meant adding a listener to each Level element's PropertyChanged event to trigger the multibinding converter to have 'changed'.在 MainViewModel 中,这意味着向每个 Level 元素的 PropertyChanged 事件添加一个侦听器,以触发多绑定转换器“更改”。 In total, my MainViewModel looked like this:总的来说,我的 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;
}

How it works: A new Level is added to the collection, and it's PropertyChanged event is listened to by the containing view model.它是如何工作的:一个新的 Level 被添加到集合中,并且它的 PropertyChanged 事件被包含的视图模型监听。 When the height of a level changes, the PropertyChanged event is fired and is picked up by the MainViewModel.当关卡的高度发生变化时,PropertyChanged 事件会被触发并被 MainViewModel 选取。 It in turn fires a PropertyChanged event for the Levels property.它依次为 Levels 属性触发 PropertyChanged 事件。 The MultiConverter is bound to the Levels property, and all changes for it trigger the converters to re-evaluate and update all of the levels combined height values. MultiConverter 绑定到 Levels 属性,它的所有更改都会触发转换器重新评估和更新所有级别的组合高度值。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM