Сложная логика пользовательских элементов управления WPF с MVVM

#c# #wpf #xaml #mvvm #revit-api

#c# #wpf #xaml #mvvm #revit-api

Вопрос:

Я создаю плагин на основе WPF (для Revit, программного обеспечения для архитектурного 3D-моделирования, но это не должно иметь значения), который довольно сложный, и я немного теряюсь.

WPF Window состоит из 2 вкладок, и каждая Tab из них является пользовательской UserControl , которую я вставляю TabItem через a Frame . В главном окне есть ViewModel место, где привязаны данные.

Одна из вкладок помогает при создании этажей в 3D-модели

часть MainWindow.xaml

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

LevelsTab.xaml UserControl на самом деле простой и содержит только кнопки для создания или удаления пользовательского пользовательского элемента управления, который я создал для графического представления этажа в пользовательском интерфейсе (скриншот ниже). Это тоже очень просто:
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 сможет создать объект уровня из информации, содержащейся в разных TextBox элементах, используя MVVM. В конце концов, в ViewModel у меня должно быть List<Level> , я думаю.
Level.cs

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

Каждый LevelDefinition из них должен быть как бы привязан к предыдущему, поскольку этаж ниже содержит информацию о высоте до уровня выше. Крайнее правое TextBox значение в LevelDefinition.xaml указывает расстояние между текущим этажом и этажом выше, следовательно, текстовое поле Height ` должно быть просто суммой его высоты ПЛЮС расстояние до уровня выше:
Пользовательский интерфейс
Конечно, дополнительный уровень сложности здесь заключается в том, что если я изменю расстояние до уровня выше на одном этаже, всем этажам выше придется обновлять высоту. Например: я меняю УРОВЕНЬ 01 (из рисунка) на 4 метра на уровень выше, высота УРОВНЯ 02 должна обновиться до 7 метров (вместо 6), а УРОВЕНЬ 03 должен стать 10 метров.

Но на данный момент я очень растерян:

  • Как мне получить эту логику привязки высоты этажа к информации на этаже ниже?
  • Как мне правильно реализовать MVVM в этом случае?

Надеюсь, мне удалось правильно объяснить ситуацию, хотя она довольно сложная, и спасибо за помощь!

Комментарии:

1. Для подключения элементов вам нужно будет использовать LinkedList, но я бы реализовал родительские и дочерние свойства в вашем LevelDefinition, а затем добавил дочернюю высоту этажа. Если вы хотите, чтобы ваше окно выполняло остановку использования Label для отображения только текста, вместо этого используйте TextBlock . Почему вы используете Frame? Также все эти имена не имеют значения, если вы не используете анимацию, но нет никаких указаний на то, что вы это делаете.

Ответ №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 значение только для чтения, но вместо этого вам действительно следует использовать a 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 событие Levels коллекции, поэтому вы можете подписываться и отменять подписку на 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;
      }
   }
}
  

Вид элемента вкладки уровни может выглядеть следующим образом. Вы можете использовать a ListBox с вашим LevelDefinition пользовательским элементом управления в качестве шаблона элемента для отображения уровней. В качестве альтернативы, вы могли бы использовать a DataGrid с редактируемыми столбцами для каждого свойства LevelViewModel , что было бы более гибким для пользователей.

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

Это упрощенный пример, проверка ввода отсутствует, недопустимые значения игнорируются при добавлении.

Комментарии:

1. Вау, спасибо @thatguy!! Это очень впечатляет! Является ли LevelViewModel дополнением к классу Level в моем сообщении или вместо этого? Кроме того, должен ли я иметь одну ViewModel на вкладку или одну ViewModel для MainWindow, которая охватывает обе вкладки?

2. @AndreaTassera В моем случае я создал его вместо этого. Однако существуют разные подходы, которые вы можете реализовать INotifyPropertyChanged в своей модели, вы можете создать отдельную Level модель и LevelViewModel тип и копировать данные каждый раз, или вы создаете тип модели представления, который переносит вашу модель, то есть вы передаете экземпляр модели в модель представления и сохраняете его там, а также адаптируете методы получения иустановщик свойств для внутреннего воздействия на модель. Для модели представления для вкладок предпочтительно создавать отдельные модели представления для каждого представления или вкладки, что также способствует использованию шаблонов данных.

3. @AndreaTassera Для получения дополнительной информации о шаблонах данных вы можете обратиться к обзору на MSDN .

4. Спасибо за информацию и за решение. Я адаптировал несколько вещей к своим потребностям, и теперь это прекрасно работает!

5. @AndreaTassera Существуют различные источники, например, MVVM: переносить или не переносить? Насколько ViewModel должен обернуть модель? . Я думаю, вы найдете и другие связанные вопросы, связанные с созданием и переносом моделей представлений.

Ответ №2:

Мне удалось реализовать это с помощью конвертера с несколькими привязками.

Предполагая, что вы где-то настроили мультиконвертер как статический ресурс, текстовый блок для отображения значения:

 <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 это означало добавление прослушивателя к событию 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;
}
  

Как это работает:
В коллекцию добавляется новый уровень, и содержащая модель представления прослушивает событие PropertyChanged. При изменении высоты уровня запускается событие PropertyChanged, которое улавливается MainViewModel. Это, в свою очередь, запускает событие PropertyChanged для свойства Levels . Мультиконвертер привязан к свойству Levels, и все изменения для него запускают преобразователи для переоценки и обновления всех уровней, объединенных значениями высоты.

Комментарии:

1. Спасибо, Пит! Это выглядит очень лаконично и элегантно! Я проведу небольшое исследование по этим темам, поскольку я не очень хорошо знаком с IMultiValueConverter