Кнопки и MVVM

174

На первый взгляд кажется, что идея применения шаблона MVVM для устранения большей части содержимого файла отделенного кода пригодна только для элементов, генерирующих значения. Если же речь заходит о кнопках, концепция начинает шататься. Объект Button инициирует событие Click. Это событие должно быть обработано в файле отделенного кода. Если модель представления фактически реализует логику кнопки (что вполне вероятно), обработчик события должен вызвать метод модели представления. Такое решение допустимо с точки зрения архитектуры, но оно все равно получается громоздким.

К счастью, у события Click существует альтернатива, идеально подходящая для MVVM. Иногда ее неформально называют «командным интерфейсом». Класс ButtonBase определяет свойства с именем Command (типа ICommand) и CommandParameter (типа object), позволяющие Button фактически обратиться с вызовом к модели представления через привязку данных.

Свойства Command и CommandParameter поддерживаются свойствами зависимости; это означает, что они могут быть приемниками привязок. Свойство Command почти всегда является приемником привязки данных, свойство CommandParameter не является обязательным. Например, оно может использоваться для различения кнопок, привязанных к одному объекту Command, и обычно выполняет примерно те же функции, что и свойство Tag.

Допустим, вы написали приложение-калькулятор, ядро которого реализовано в форме модели представления, задаваемой свойством DataContext. Экземпляр кнопки калькулятора для выполнения команды сложения (+) может создаваться в разметке XAML:

<Button Content="+"
        Command="{Binding CalculateCommand}"
        CommandParameter="add" />

Это означает, что модель представления содержит свойство CalculateCommand типа ICommand, которое, вероятно, определяется следующим образом:

public ICommand CalculateCommand { protected set; get; }

Модель представления должна инициализировать свойство CalculateComnand, задавая ему экземпляр класса, реализующего интерфейс ICommand, который определяется следующим образом:

public interface ICommand
{
    void Execute(object param);
    bool CanExecute(object param);
    event EventHandler<object> CanExecuteChanged;
}

При щелчке на этой кнопке вызывается метод Execute объекта, на который ссылается CalculateCommand, с аргументом "add". Получается, что Button обращается с вызовом непосредственно к модели представления (а вернее к классу, содержащему метод Execute).

Два других члена интерфейса ICommand относятся к проверке возможности выполнения конкретной команды в конкретный момент. Если команда в данное время является недействительной (например, калькулятор не может выполнить сложение из-за отсутствия введенного числа), соответствующая кнопка должна быть заблокирована.

Вот как работает этот механизм: в процессе разбора и загрузки XAML во время выполнения свойству Command объекта Button задается привязка (в данном примере) к объекту CalculateCommand. Button назначает обработчик для события CanExecuteChanged и вызывает метод CanExecute объекта с аргументом (в данном примере) "add". Если CanExecute вернет false, кнопка Button блокирует себя. В дальнейшем Button повторно вызывает CanExecute при инициировании события CanExecuteChanged.

Чтобы включить команду в вашу модель представления, необходимо предоставить класс, реализующий интерфейс ICommand. Однако такому классу с большой вероятностью потребуется обращаться к свойствам класса модели представления, и наоборот.

Возникает вопрос: нельзя ли эти классы совместить?

Теоретически можно, но только в том случае, если одни и те же методы Execute и CanExecute могут использоваться для всех кнопок страницы; для этого каждая кнопка должна обладать уникальным значением CommandParameter, по которому ее можно будет идентифицировать. Но сначала я хотел бы продемонстрировать стандартный способ реализации команд в модели представления.

Класс DelegateCommand

Перепишем приложение SimpleKeypad, созданное ранее так, чтобы оно использовало модель представления для накопления нажатий клавиш и генерирования отформатированной строки. Кроме реализации интерфейса INotifyPropertyChanged, модель представления также обрабатывает команды от всех кнопок клавиатуры. Обработчики Click больше не нужны.

А теперь проблема: чтобы модель представления обрабатывала команды кнопок, она должна обладать одним или несколькими свойствами типа ICommand; это означает, что нам понадобится один или несколько классов, реализующих интерфейс ICommand. Для реализации ICommand эти классы должны содержать методы Execute и CanExecute, а также событие CanExecuteChanged. При этом тела этих методов безусловно должны взаимодействовать с другими частями модели представления.

Проблема решается определением методов Execute и CanExecute в классе модели представления под другими, уникальными именами. Затем определяется специальный класс, который реализует ICommand, а также непосредственно вызывает методы модели представления.

Этот специальный класс часто называется DelegateCommand. Осмотревшись, вы найдете несколько разных реализаций этого класса, включая реализацию из инфраструктуры Microsoft Prism, которая помогает разработчикам реализовывать шаблон MVVM в WPF (Windows Presentation Foundation) и Silverlight. Ниже приведена моя реализация.

Мой класс DelegateCommand реализует интерфейс ICommand; это означает, что он содержит методы Execute и CanExecute, а также событие CanExecuteChanged. Но как выясняется, DelegateCommand нужен еще один метод для инициирования события CanExecuteChanged. Мы назовем его RaiseCanExecuteChanged. Прежде всего следует определить интерфейс, который реализует ICommand и содержит этот дополнительный метод:

using System.Windows.Input;

namespace WinRTTestApp
{
    public interface IDelegateCommand : ICommand
    {
        void RaiseCanExecuteChanged();
    }
}

Класс DelegateCommand реализует интерфейс IDelegateCommand и использует простые (но полезные) обобщенные делегаты, определенные в пространстве имен System. Эти предопределенные делегаты называются Action и Func и получают от 1 до 16 аргументов. Делегаты Func возвращают объект конкретного типа, а делегаты Action этого не делают. Делегат Action<object> представляет метод с одним аргументом object и возвращаемым значением void - сигнатура метода Execute. Делегат Func<object, bool> представляет метод с аргументом object, возвращающий bool; это сигнатура метода CanExecute. DelegateCommand определяет два поля указанных типов для хранения методов с такими сигнатурами:

using System;

namespace WinRTTestApp
{
    public class DelegateCommand : IDelegateCommand
    {
        Action<object> execute;
        Func<object, bool> canExecute;

        // Событие, необходимое для ICommand
        public event EventHandler CanExecuteChanged;

        // Два конструктора
        public DelegateCommand(Action<object> execute, Func<object, bool> canExecute)
        {
            this.execute = execute;
            this.canExecute = canExecute;
        }
        
        public DelegateCommand(Action<object> execute)
        {
            this.execute = execute;
            this.canExecute = this.AlwaysCanExecute;
        }

        // Методы, необходимые для ICommand
        public void Execute(object param)
        {
            execute(param);
        }

        public bool CanExecute(object param)
        {
            return canExecute(param);
        }

        // Метод, необходимый для IDelegateCommand
        public void RaiseCanExecuteChanged()
        {
            if (CanExecuteChanged != null)
                CanExecuteChanged(this, EventArgs.Empty);
        }

        // Метод CanExecute по умолчанию
        private bool AlwaysCanExecute(object param)
        {
            return true;
        }
    }
}

Класс реализует методы Execute и CanExecute, но эти реализации просто вызывают методы, сохраненные в полях. Поля заполняются конструктором класса по переданным аргументам.

Например, если в модели представления имеется команда вычисления, то в ней может быть определено свойство CalculateCommand:

public IDelegateCommand CalculateCommand { protected set; get; }

Модель представления также определяет два метода с именами ExecuteCalculate() и CanExecuteCalculate():

private void ExecuteCalculate(object param)
{
    // ...
}

private bool CanExecuteCalculate(object param)
{
    // ...
}

Конструктор класса модели представления создает свойство CalculateCommand, создавая экземпляр DelegateCommand с этими двумя методами:

this.CalculateCommand = new DelegateCommand(ExecuteCalculate, CanExecuteCalculate);

Теперь, когда вы поняли общую идею, рассмотрим модель представления для клавиатуры. Для вводимого и отображаемого текста модель представления определяет два свойства: InputString и отформатированную версию DisplayText.

Модель представления также определяет два свойства типа IDelegateCommand с именами AddCharacterCommand (для цифровых и символьных клавиш) и DeleteCharacterCommand. Эти свойства создаются посредством создания экземпляра DelegateCommand с методами ExecuteAddCharacter(), ExecuteDeleteCharacter() и CanExecuteDeleteCharacter(). Метода CanExecuteAddCharacter() не существует, потому что ввод по нажатию клавиши всегда действителен.

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WinRTTestApp
{
    public class KeypadViewModel : INotifyPropertyChanged
    {
        string inputString = "";
        string displayText = "";
        char[] specialChars = { '*', '#' };

        public event PropertyChangedEventHandler PropertyChanged;

        // Конструктор
        public KeypadViewModel()
        {
            this.AddCharacterCommand = new DelegateCommand(ExecuteAddCharacter);
            this.DeleteCharacterCommand =
                new DelegateCommand(ExecuteDeleteCharacter, CanExecuteDeleteCharacter);
        }

        // Открытые свойства
        public string InputString
        {
            protected set
            {
                bool previousCanExecuteDeleteChar = this.CanExecuteDeleteCharacter(null);

                if (this.SetProperty<string>(ref inputString, value))
                {
                    this.DisplayText = FormatText(inputString);

                    if (previousCanExecuteDeleteChar != this.CanExecuteDeleteCharacter(null))
                        this.DeleteCharacterCommand.RaiseCanExecuteChanged();
                }
            }

            get { return inputString; }
        }

        public string DisplayText
        {
            protected set { this.SetProperty<string>(ref displayText, value); }
            get { return displayText; }
        }

        // Реализация ICommand
        public IDelegateCommand AddCharacterCommand { protected set; get; }

        public IDelegateCommand DeleteCharacterCommand { protected set; get; }

        // Методы Execute() и CanExecute() 
        void ExecuteAddCharacter(object param)
        {
            this.InputString += param as string;
        }

        void ExecuteDeleteCharacter(object param)
        {
            this.InputString = this.InputString.Substring(0, this.InputString.Length - 1);
        }

        bool CanExecuteDeleteCharacter(object param)
        {
            return this.InputString.Length > 0;
        }

        // Закрытый метод, вызываемый из InputString
        private string FormatText(string str)
        {
            bool hasNonNumbers = str.IndexOfAny(specialChars) != -1;
            string formatted = str;

            if (hasNonNumbers || str.Length < 4 || str.Length > 10)
            {
            }
            else if (str.Length < 8)
            {
                formatted = String.Format("{0}-{1}", str.Substring(0, 3),
                                                     str.Substring(3));
            }
            else
            {
                formatted = String.Format("({0}) {1}-{2}", str.Substring(0, 3),
                                                           str.Substring(3, 3),
                                                           str.Substring(6));
            }
            return formatted;
        }

        protected bool SetProperty<T>(ref T storage, T value,
                                      [CallerMemberName] string propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Метод ExecuteAddCharacter() предполагает, что в параметре передается символ, введенный пользователем. Так одна команда совместно используется для нескольких кнопок.

Метод CanExecuteDeleteCharacter() возвращает true только при наличии символов, которые можно удалить; в противном случае кнопка удаления должна быть заблокирована. Но этот метод вызывается только при начальном установлении привязки, а потом только при инициировании события CanExecuteChanged. Логика инициирования этого события находится в set-методе InputString(), который сравнивает возвращаемые значения CanExecuteDeleteCharacter до и после изменения входной строки.

Файл XAML создает экземпляр модели представления как ресурс, а затем определяет DataContext в Grid. Обратите внимание на простоту привязок Command для тринадцати элементов управления Button и использование CommandParameter для цифровых и символьных клавиш:

<Page ...>

    <Page.Resources>
        <local:KeypadViewModel x:Key="viewModel" />
    </Page.Resources>

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}"
          DataContext="{StaticResource viewModel}">

        <Grid HorizontalAlignment="Center"
              VerticalAlignment="Center"
              Width="288">

            <Grid.Resources>
                <Style TargetType="Button">
                    <Setter Property="ClickMode" Value="Press" />
                    <Setter Property="HorizontalAlignment" Value="Stretch" />
                    <Setter Property="Height" Value="72" />
                    <Setter Property="FontSize" Value="36" />
                </Style>
            </Grid.Resources>

            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>

            <Grid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="Auto" />
                </Grid.ColumnDefinitions>

                <Border Grid.Column="0"
                        HorizontalAlignment="Left">

                    <TextBlock Text="{Binding DisplayText}"
                               HorizontalAlignment="Right"
                               VerticalAlignment="Center"
                               FontSize="24" />
                </Border>

                <Button Content="⇦"
                        Command="{Binding DeleteCharacterCommand}"
                        Grid.Column="1"
                        FontFamily="Segoe Symbol"
                        HorizontalAlignment="Left"
                        Padding="0"
                        BorderThickness="0" />
            </Grid>

            <Button Content="1"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="1"
                    Grid.Row="1" Grid.Column="0" />

            <Button Content="2"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="2"
                    Grid.Row="1" Grid.Column="1" />

            <Button Content="3"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="3"
                    Grid.Row="1" Grid.Column="2" />

            <Button Content="4"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="4"
                    Grid.Row="2" Grid.Column="0" />

            <Button Content="5"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="5"
                    Grid.Row="2" Grid.Column="1" />

            <Button Content="6"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="6"
                    Grid.Row="2" Grid.Column="2" />

            <Button Content="7"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="7"
                    Grid.Row="3" Grid.Column="0" />

            <Button Content="8"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="8"
                    Grid.Row="3" Grid.Column="1" />

            <Button Content="9"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="9"
                    Grid.Row="3" Grid.Column="2" />

            <Button Content="*"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="*"
                    Grid.Row="4" Grid.Column="0" />

            <Button Content="0"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="0"
                    Grid.Row="4" Grid.Column="1" />

            <Button Content="#"
                    Command="{Binding AddCharacterCommand}"
                    CommandParameter="#"
                    Grid.Row="4" Grid.Column="2" />
        </Grid>
    </Grid>
</Page>

Самая неинтересная часть этого проекта - файл отделенного кода, который теперь не содержит ничего, кроме вызова InitializeComponent. Задача выполнена.

Измененное приложение с экранной клавиатурой, использующее MVVM

Вы можете использовать возможности Windows Runtime для запуска мобильного приложения для интернет-магазина. Ранее, при рассмотрении разработки магазина на ASP.NET MVC мы не описали важные детали о том как открыть Интернет-магазин официально оформив документы, создав службы поддержки, доставки, организовав работу курьеров и т.д.

Пройди тесты
Лучший чат для C# программистов