воскресенье, 29 сентября 2013 г.

Показ дочерних View в рамках патерна MVVM

Время от времени, участвую в обсуждениях, как правильно в рамках паттерна MVVM показывать дополнительные (дочерние View) в отдельных окнах. Т.е. когда смотришь на диаграмму иллюстрирующую паттерн, все ясно: View знает про ViewModel только на уровне имен свойств указанных в биндинге, ViewModel вообще ничего не знает про View. Но вот, пользователь нажимает на кнопку и нам надо создать новые View и ViewModel для того, чтобы показать это все в отдельном окне... Что делать? Вот об этом сегодня и пойдет речь.

Как я уже сказал, этот вопрос вызывает массу споров, что делать, ведь знание одной части об другой нарушит всю красоту паттерна. Как практически у всех задач, у этой есть несколько решений. Давайте сейчас три из них и рассмотрим. Заранее прошу прощение, за простоту примеров, но я стремлюсь показать идею, а не сложный пример. Пусть у нас в программе есть главное окно со своим ViewModel, в котором храниться коллекция int-ов и две команды. По первой будет открываться View и ViewModel предназначенные для редактирования первого элемента коллекции (окно будет открываться в модальном режиме), по второй, будет открываться View, в который в качестве контекста будет передаваться наша коллекция и он ее будет показывать.
Первый View будет вот такой:

<UserControl x:Class="WpfApplication41.View.EditView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             >
    <Grid>
        <TextBox Text="{Binding Value}" Margin="5" Width="100" />
    </Grid>
</UserControl>

Его ViewModel тоже не будет отличаться сложностью:

class EditViewModel
{
    public int Value { get; set; }
}

Для показа, мы даже ViewModel создавать не будем, а обойдемся только View:

<UserControl x:Class="WpfApplication41.View.ShowListView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d" >
    <ListBox ItemsSource="{Binding}" Width="100" />
</UserControl>

Разметка главной формы:

<Window x:Class="WpfApplication41.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="150" Width="300">
    <StackPanel>
        <Button Content="Редактировать первый элемент" Command="{Binding ShowFirstViewCommand}" />
        <Button Content="Показать список" Command="{Binding ShowSecondViewCommand}" />
    </StackPanel>
</Window>

ViewModel главной формы:

class MainViewModel
{
    public ICommand ShowFirstViewCommand { get; set; }
 
    public ICommand ShowSecondViewCommand { get; set; }
 
    private ObservableCollection<int> _data;
 
    public MainViewModel()
    {
        _data = new ObservableCollection<int>(new int[] { 1, 2, 3, 4 });
        ShowFirstViewCommand = new SimpleCommand(ShowFirstView);
        ShowSecondViewCommand = new SimpleCommand(ShowSecondView);
    }
 
    public void ShowFirstView()
    {
 
    }
 
    public void ShowSecondView()
    {
 
    }
}

Все сцена готова, осталось дописать код в обработчики команд. Начнем.
1. Никто ни про кого ничего не знает
Сторонниками "чистых" паттернов этот подход признается наиболее правильным, поэтому с него и начнем. В проект добавляется специальный класс, который получает информацию о том, с каком режиме показывать новое окно, признак для выбора создаваемого View и DataContext для него и метод, который надо вызвать по закрытию окна.
Например, такой класс может иметь вид:

class ViewShower
{
    public static void Show(int p_viewIndex, object p_dataContext, bool p_isModal, Action<bool?> p_closeAction)
    {
        UserControl control = null;
        switch (p_viewIndex)
        {
            case 0:
                control = new EditView();
                break;
            case 1:
                control = new ShowListView();
                break;
            default:
                throw new ArgumentOutOfRangeException("p_viewIndex", "Такого индекса View не существует");
        }
        if (control != null)
        {
            Window wnd = new Window();
            wnd.SizeToContent = SizeToContent.WidthAndHeight;
            control.DataContext = p_dataContext;
            StackPanel sp = new StackPanel();
            sp.Children.Add(control);
            Button applyButton = new Button();
            applyButton.Content = "Принять";
            applyButton.Click += (s, e) => { if (p_isModal) wnd.DialogResult = true; else wnd.Close(); };
            StackPanel buttonPanel = new StackPanel();
            buttonPanel.Orientation = Orientation.Horizontal;
            buttonPanel.Children.Add(applyButton);
            sp.Children.Add(buttonPanel);
            wnd.Content = sp;
            wnd.Closed += (s, e) => p_closeAction(wnd.DialogResult);
            if (p_isModal)
            {
                Button cancelButton = new Button();
                cancelButton.Content = "Отмена";
                cancelButton.Click += (s, e) => wnd.DialogResult = false;
                buttonPanel.Children.Add(cancelButton);
                wnd.ShowDialog();
            }
            else
            {
                wnd.Show();
            }
        }
    }
}

В этом случае обработчики команд в главном ViewModel будет иметь вид:
 
public void ShowFirstView()
{
    EditViewModel vm = new EditViewModel() { Value = _data[0] };
    ViewShower.Show(0, vm, true, b => { if (b != null && b.Value) _data[0] = vm.Value; });
}
 
public void ShowSecondView()
{
    ViewShower.Show(1, _data, false, b => {  });
}

Теперь. при нажатии на первую кнопку, мы можем редактировать в модальном режиме первый элемент коллекции, при нажатии на вторую видеть список.
Запускаем приложение:
Нажимаем вторую кнопку:
Нажимаем первую кнопку:
Меняем значение на 5 и нажимаем принять:

Плюсом этого подхода является чистота паттерна. А вот минусов будет чуть больше. При добавлении нового View, нам для его показа надо будет не только писать вызов метода Show, но и править сам метод Show. А это, при командной работе может оказаться чревато, если двое правят его одновременно и для очередного признака напишут вызов конструктора разных View. Мерджить будет забавно...

2. ViewModel вызывает конструктор View
Идея похожая, но теперь во View мы вызываем конструктор, а метод Show получает не признак и ViewModel, а только View:

public static void Show(Control p_view, bool p_isModal, Action<bool?> p_closeAction)
{
    if (p_view != null)
    {
        Window wnd = new Window();
        wnd.SizeToContent = SizeToContent.WidthAndHeight;
        StackPanel sp = new StackPanel();
        sp.Children.Add(p_view);
        Button applyButton = new Button();
        applyButton.Content = "Принять";
        applyButton.Click += (s, e) => { if (p_isModal) wnd.DialogResult = true; else wnd.Close(); };
        StackPanel buttonPanel = new StackPanel();
        buttonPanel.Orientation = Orientation.Horizontal;
        buttonPanel.Children.Add(applyButton);
        sp.Children.Add(buttonPanel);
        wnd.Content = sp;
        wnd.Closed += (s, e) => p_closeAction(wnd.DialogResult);
        if (p_isModal)
        {
            Button cancelButton = new Button();
            cancelButton.Content = "Отмена";
            cancelButton.Click += (s, e) => wnd.DialogResult = false;
            buttonPanel.Children.Add(cancelButton);
            wnd.ShowDialog();
        }
        else
        {
            wnd.Show();
        }
    }
}

Соответственно методы обработчики команд будут иметь вид:

public void ShowFirstView()
{
    EditViewModel vm = new EditViewModel() { Value = _data[0] };
    EditView view = new EditView() { DataContext = vm };
    ViewShower.Show(view, true, b => { if (b != null && b.Value) _data[0] = vm.Value; });
}
 
public void ShowSecondView()
{
    ShowListView view = new ShowListView() { DataContext = _data };
    ViewShower.Show(view, false, b => { });
}

В этом случае чистота паттерна нарушается, но не придется при добавлении каждого нового View редактировать метод Show.

3. Конструктор View не вызываем но про View знаем
Это нечто промежуточное между первым и вторым подходом, кстати по аналогичной схеме сделан переход между  страницами в Windosw Store приложениях. Здесь мы в конструктор передаем ViewModel и тип, на основе которого надо создать View:

public static void Show(Type p_viewType, object p_dataContext, bool p_isModal, Action<bool?> p_closeAction)
{
    Control view = null;
    var constructor = p_viewType.GetConstructor(new Type[0]);
    if (constructor != null)
    {
        view = constructor.Invoke(new object[0]) as UserControl;
    }
    if (view != null)
    {
        view.DataContext = p_dataContext;
        Window wnd = new Window();
        wnd.SizeToContent = SizeToContent.WidthAndHeight;
        StackPanel sp = new StackPanel();
        sp.Children.Add(view);
        Button applyButton = new Button();
        applyButton.Content = "Принять";
        applyButton.Click += (s, e) => { if (p_isModal) wnd.DialogResult = true; else wnd.Close(); };
        StackPanel buttonPanel = new StackPanel();
        buttonPanel.Orientation = Orientation.Horizontal;
        buttonPanel.Children.Add(applyButton);
        sp.Children.Add(buttonPanel);
        wnd.Content = sp;
        wnd.Closed += (s, e) => p_closeAction(wnd.DialogResult);
        if (p_isModal)
        {
            Button cancelButton = new Button();
            cancelButton.Content = "Отмена";
            cancelButton.Click += (s, e) => wnd.DialogResult = false;
            buttonPanel.Children.Add(cancelButton);
            wnd.ShowDialog();
        }
        else
        {
            wnd.Show();
        }
    }
}

Ну и наши методы:

public void ShowFirstView()
{
    EditViewModel vm = new EditViewModel() { Value = _data[0] };
    ViewShower.Show(typeof(EditView), vm, true, b => { if (b != null && b.Value) _data[0] = vm.Value; });
}
 
public void ShowSecondView()
{
    ViewShower.Show(typeof(ShowListView), _data, false, b => { });
}

Все. Какой из методов выбрать? Это зависит от вашей ситуации. Если одни и те же ViewModel планируется использовать, например, в Windows и Windows Store приложениях, то скорее всего первый, а вот если View у вас под одну платформу, то второй случай понятность структуры приложения за счет применения MVVM повышает, а вот лишних проблем с методом Show нет.

2 комментария:

  1. Большое спасибо за замечательную статью! Она мне очень пригодилась.

    P. S. Вместо номера представления (p_viewIndex) я бы использовал enum. С перечислением гораздо нагляднее. Однако его также придется дополнять при добавлении в программу нового окна.

    ОтветитьУдалить
    Ответы
    1. В блоге есть вторая часть про дочерние View. Там подход универсальней.

      Удалить