понедельник, 6 апреля 2009 г.

Triggers + Actions = Fun

Привет, друзья!

Сегодня мы посмотрим на часть API MS Expression Blend 3.0 триггеры и действия (Triggers & Actions): что это такое, как их создавать и использовать. На закуску мы разработаем пару полезных действий: обновление стиля элемента по действию пользователя, и реализацию Command'a в Silverlight приложении, что сделает наш MVVM еще более эмвевеэмнее :).
 

Что и как?


Проще всего представить себе эту парочку одним предложением:
Когда произойдет А сделай Б.
Здесь А - это триггер, Б - действие. Например:
  • Когда пользователь нажмет эту кнопку (trigger), показать MessageBox (action).
  • Когда страница загрузится (trigger), установить фокус в это поле ввода (action).
  • Когда пропадет сетевое подключение (trigger), перейти в автономный режим (action).
  • Когда рак на горе свистнет (trigger), получить прибавку к зарплате (action).

Триггеры


Очень похожи на триггеры в WPF, но с бoльшей долей свободы. Если раньше мы были ограничены триггерами, реагирующими на изменения dependency properties или на изменения в привязанных (data bound) свойств, то теперь триггером может быть что угодно (см. список выше).

У каждого триггера есть коллекция действий, которые должны произойти в момент события. Все, что нужно для написания собственного триггера, это определить наступление того самого события и проинформировать все свои действия о том, что пора выполниться.

Запутанно? Давайте посмотрим на пример. В подтверждение широкого разнообразия природы триггеров, мы напишем триггер, который выполняет действия, "когда рак на горе свистнет" :). Вот чудо-код:
using System;
using System.Threading;
using System.Windows;
using System.Windows.Threading;

using Microsoft.Expression.Interactivity;

namespace TriggersActions
{
  public class CrabWhistle : TriggerBase<UIElement>
  {
    private readonly Random _rand = new Random();
    private Dispatcher _dispatcher;

    protected override void OnAttached()
    {
      _dispatcher = Dispatcher;

      new Timer(CrabLifeCycle, null, 100, 100);
    }

    private void CrabLifeCycle(object state)
    {
      if (_rand.Next(20) == 10)
      {
        _dispatcher.BeginInvoke(() => InvokeActions(null));
      }
    }
  }
}
Чтобы стать триггером, мы унаследовались от TriggerBase<UIElement>. Generic-параметр сужает круг элементов, к которым может быть прикреплен наш триггер до UIElement'a. Как и с поведениями (Behaviors), наш триггер будет уведомлен о прикреплении к UIElement'у, вызовом метода OnAttached(). Здесь-то мы и начинаем следить за раком: когда же он свистнет? Мы запустили таймер и там доверились генератору случайных чисел. Не самый достоверный показатель события "рак свистит", но достаточный для примера :).

Когда наступает "тот самый" момент, мы вызываем метод InvokeActions(), который просто проходится по коллекции действий и заставляет их выполниться. Коллекция действий всегда доступна разработчику триггеров по свойству Actions.

Из триггера мы так же можем обратиться к объекту, к которому прикреплен сам триггер (свойство AssociatedObject). Значит, мы можем подписаться и на события объекта и реагировать вызовом действий. Например, добавили триггер к кнопке и слушаем ее событие Click. В обработчике события вызываем InvokeActions().

Итак, мы посмотрели на триггеры: объекты, инициирующие действия, по каким-то условиям. Но что же представляют собой действия?

Действия



Действия - это объекты типа TriggerAction, реализующие метод Invoke(). Именно этот метод вызывается методом InvokeActions(), когда срабатывает триггер. "Позвольте, - скажете вы, - это же привязывает действия к триггерам". И будете абсолютно правы: действия имеют смысл лишь в контексте триггера. Давайте посмотрим на код действия, поднимающего нашу зарплату :) :
using System;
using System.Windows.Controls;

using Microsoft.Expression.Interactivity;

namespace TriggersActions
{
  public class IncreaseSalary : TargetedTriggerAction<TextBlock>
  {
    protected override void Invoke(object param)
    {
      Target.Text = string.Format("Зарплата поднята: {0}", DateTime.Now);
    }
  }
}
Здесь мы унаследовались от потомка TriggerAction, а именно от TargetedTriggerAction<TextBlock> - разновидность действия из API Blend'a, позволяющая указать на ком будет выполнятся действие. Мы выбрали TextBlock, чтобы обрадовать пользователя о повышении зарплаты. Целевой TextBlock будет определен в xaml.

Стоит отметить, что действия могут быть исключены из обработки, установкой свойства IsEnabled в false. Например, действие IncreaseSalary может устанавливать это значение в false, если на дворе случится какой-нибудь кризис. Но, поскольку это не наш случай, у нашего действия значение свойства IsEnabled оставлено по умолчанию: true.

Поскольку действия имеют смысл только с триггерами, давайте соберем все вместе и посмотрим, на реализацию "Поднять зарплату, когда рак на горе свистнет".

Триггеры + Действия


После компиляции в Blend'e сильверлайт-проекта с определенными выше классами триггера и действия, на вкладке Behaviors мы найдем только IncreaseSalary:



Перетащив это действие на единственный TextBlock, определенный в нашем xaml-файле, мы получим следующую картину:


Expression Blend создал для нас триггер типа EventTrigger (поставляемый вместе с API), и поместил наше действие в качестве действий триггера:
<TextBlock VerticalAlignment="Top" Text="TexBlock1">
 <i:Interaction.Triggers>
  <i:EventTrigger EventName="Loaded">
   <ta:IncreaseSalary/>
  </i:EventTrigger>
 </i:Interaction.Triggers>
</TextBlock>
EventTrigger - весьма полезная штуковина, позволяющая при наступлении CLR события выполнять actions. Но нам-то нужно не простое CLR-событие, а событие "рак свистнул". С интерфейсом Blend'a менять тип триггера одно удовольствие:

1. В Objects and Timeline под нашим TextBlock'ом выбираем IncreaseSalary.
2. На вкладке свойств (Properties) тыцькаем New напротив TriggerType.
3. В появившемся окошке выбираем наш триггер CrabWhistle
 


После всех проделанных операций мы получим следующую xaml-разметку:
<UserControl
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:i="clr-namespace:Microsoft.Expression.Interactivity;assembly=Microsoft.Expression.Interactivity" xmlns:local="clr-namespace:TriggersActions"
 x:Class="TriggersActions.MainControl"
 Width="400" Height="400">
 <Grid x:Name="LayoutRoot">
 <TextBlock VerticalAlignment="Top" Text="TextBlock" TextWrapping="Wrap">
 <i:Interaction.Triggers>
  <local:CrabWhistle>
   <local:IncreaseSalary/>
  </local:CrabWhistle>
 </i:Interaction.Triggers>
</TextBlock>
</Grid>
</UserControl>

Запускаем приложение, и после первого же свистка рака наша зарплата повышается. Свисти, дружок, свисти :)!
Стоит обратить внимание на то, что мы можем указать IncreaseSalary выбрать другой TextBlock для сообщения добрых вестей. Достаточно установить ему свойство TargetName. Например, следующий код будет так же прекрасно работать:
<Grid x:Name="LayoutRoot">
 <i:Interaction.Triggers>
  <local:CrabWhistle>
   <local:IncreaseSalary TargetName="txtInfo"/>
  </local:CrabWhistle>
 </i:Interaction.Triggers>
 <TextBlock Name="txtInfo" Text="TextBlock"/>
</Grid>
Единственный элемент, на который мы не обратили до сих пор внимание - это Attached Property под названием Interaction.Triggers. Суть работы этого свойства ничем не отличается от сути работы аналогичного свойства для поведений Interaction.Behaviors (см. описание в прошлом посте).

Прежде чем мы перейдем к созданию интересных действий, давайте подведем итоги:

1. Прикрепленное свойство Interaction.Triggers позволяет определить коллекцию триггеров для UIElement'a (точнее: для любого DependencyObject'a).
2. Триггер содержит коллекцию действий, которые выполняются вызовом метода InvokeActions().
3. Каждое действие реализует метод Invoke(), вызываемый при срабатывании триггера.

Действие: обновление стиля.


Очень просто в реализации:
using System.Windows;

using Microsoft.Expression.Interactivity;

namespace TriggersActions
{
  public class SetStyleAction : TargetedTriggerAction<FrameworkElement>
  {
    public static readonly DependencyProperty StyleProperty =
      DependencyProperty.Register("Style",
                                  typeof(Style),
                                  typeof(SetStyleAction),
                                  new PropertyMetadata(null));

    public Style Style
    {
      get
      {
        return (Style)GetValue(StyleProperty);
      }
      set
      {
        SetValue(StyleProperty, value);
      }
    }

    protected override void Invoke(object parameter)
    {
      if (Target != null)
      {
        Target.SetValue(FrameworkElement.StyleProperty, Style);
      }
    }
  }
}
Как видите, мы определили свойство Style, и в момент срабатывания триггера просто устанавливаем значение стиля FrameworkElement'у. Так, следующий код, при наведении мышкой установит стиль Canvas'у, превращая цвет фона в зеленый:
<UserControl
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="clr-namespace:Microsoft.Expression.Interactivity;assembly=Microsoft.Expression.Interactivity"
xmlns:ta="clr-namespace:TriggersActions"  
x:Class="TriggersActions.MainControl"
Width="400" Height="400">
<UserControl.Resources>
  <Style x:Key="CanvasStyle" TargetType="Canvas">
     <Setter Property="Background" Value="Green"/>
   </Style>    
</UserControl.Resources>
<Grid x:Name="LayoutRoot">
    <Canvas Width="200" Height="200">
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="MouseEnter">
                <ta:SetStyleAction Style="{StaticResource CanvasStyle}"/>
            </i:EventTrigger>
        </i:Interaction.Triggers>
 <TextBlock Text="Hover me"/>
    </Canvas>
</Grid>
</UserControl>
    
Просто, не правда ли?

Действие: вызов команд ViewModel'a.


В сборке Microsoft.Expression.Interactivity есть класс, под названием InvokeCommandAction. Но это действие предназначено для работы исключительно в контексте поведения (Behavior). Во время вызова действия происходит поиск свойства типа ICommand с именем CommandName у поведения, к которому прикрепелен наш InvokeCommandAction. Мягко говоря, не совсем то, что нам нужно.

Мы хотим создать действие, которое будет выполнятся в рамках триггера (не поведения), и будет запускать команду с указанным именем, определенную в DataContext'e. Т.е создать аналог следующего WPF кода:
<Button Command={Binding DoSomethingCommand} />
Приступим!

Код C#:
using System.Windows;
using System.Windows.Input;

using Microsoft.Expression.Interactivity;

namespace TriggersActions
{
 public class InvokeCommandAction : TriggerAction<FrameworkElement>
 {
  public static readonly DependencyProperty CommandNameProperty=
   DependencyProperty.Register("CommandName", typeof(string), typeof(InvokeCommandAction), 
                               new PropertyMetadata(DependencyProperty.UnsetValue));

  public string CommandName
  {
   get { return (string)GetValue(CommandNameProperty); }
   set { SetValue(CommandNameProperty, value); }
  }

  protected override void Invoke(object parameter)
  {
   var command = ResolveCommand();
   if (command != null && command.CanExecute(parameter))
   {
    command.Execute(parameter);
   }
  }

  private ICommand ResolveCommand()
  {
   var dc = AssociatedObject.GetValue(FrameworkElement.DataContextProperty);
   return (dc.GetType()).GetProperty(CommandName).GetValue(dc, null) as ICommand;
  }
 }
}
Мы выставляем наружу строковое свойство CommandName, и в методе Invoke() пытаемся найти соответствующую этому имени команду. Для поиска команды мы берем DataContext объекта, к которому прикреплен наш TriggerAction, и через reflection пытаемся получить значение свойства с нужным именем.

Но почему бы просто не определить свойство типа ICommand и использовать привычный {Binding SomeCommand}? Дело в классе Binding. Для того, чтобы значению свойства присвоить Binding-выражение, наличия одного лишь DependencyProperty недостаточно в Silverlight'e. Необходимо чтобы объект, в котором определено это dependency-свойство был типа FrameworkElement (именно такие ограничения накладывает Silverlight на использование Binding'a). А TriggerActoin является потомком DependencyObject'a. Ну да не беда. Чем горевать (или радоваться) об отсутствии множественного наследования в .NET, мы взяли на вооружение Reflection.

Пример


Давайте посмотрим на InvokeCommandAction в действии. Мы создадим приложение, показывающее текущее время по требованию пользователя. ViewModel имеет два свойства: команда для обновления текущего времени, и свойство, возвращающее текущее время:
using System;
using System.ComponentModel;
using System.Windows.Input;
using Microsoft.Expression.Interactivity.Input;

namespace TriggersActions
{
  public class MainControlViewModel : INotifyPropertyChanged
  {
    public event PropertyChangedEventHandler PropertyChanged;

    public ICommand UpdateTimeCommand { get;  private set; }

    public string Time { get { return DateTime.Now.ToString(); } }

    public MainControlViewModel()
    {
      UpdateTimeCommand = new ActionCommand(() => InvokePropertyChanged("Time"));
    }

    private void InvokePropertyChanged(string propertyName)
    {
      var handler = PropertyChanged;
      if (handler != null)
      {
        handler(this, new PropertyChangedEventArgs(propertyName));
      }
    }
  }
}
Здесь класс ActionCommand - это реализация DelegateCommand'a, из Composite Application Guidance for WPF. Самим создавать класс не придется: в сборке Microsoft.Expression.Interactivity он уже определен :). Правда, он не позволяет переопределить метод CanExecute(), но вы всегда можете написать свой собственный ActionCommand - не велика беда.

XAML выглядит так:
<UserControl
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:i="clr-namespace:Microsoft.Expression.Interactivity;assembly=Microsoft.Expression.Interactivity"
 xmlns:ta="clr-namespace:TriggersActions"  
 x:Class="TriggersActions.MainControl"
 Width="400" Height="400">
    <UserControl.Resources>
        <ta:MainControlViewModel x:Key="vm"/>
    </UserControl.Resources>
    <Grid x:Name="LayoutRoot" DataContext="{StaticResource vm}" >
        <Canvas>
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="MouseEnter">
                    <ta:InvokeCommandAction CommandName="UpdateTimeCommand"/>
                </i:EventTrigger>
            </i:Interaction.Triggers>
            <TextBlock x:Name="textBlock" 
                       Canvas.Left="31" Canvas.Top="46"
                       Text="{Binding Time}"
                       TextWrapping="Wrap"/>
        </Canvas>
    </Grid>
</UserControl>
Теперь каждый раз, при наведении мышки на текст мы получим самое свежее время. Слава MVVM'у :)!

Итак


Для тех, кто пролистал весь этот пост, в поисках выводов :). Мы посмотрели на работу триггеров и действий. Написали никому не нужный триггер "свистящий рак", который свистел по законам рандомайзера. К сожалению, поместить рака на гору мне не удалось - очень много кода не способствовало бы пониманию концепции. Затем мы натравили на каждый свисток рака действие, повышающее зарплату пользователя (сложно назвать это действие бесполезным :)). Ну и самый конец статьи, возможно будет интересен приверженцам MVVM-подхода в Silverlight'e: Мы реализовали типичный DelegateCommand.

С нетерпением буду ждать ваших комментариев и пожеланий :)!

Отличного настроения и программирования, друзья!

Код к статье: здесь.

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

  1. Спасибо за статью.

    В последнее время активно занимаюсь решением инфраструктурных проблем в Silverlight. Встречал пример, который аналогичен твоему InvokeCommandAction в фреймверке MVVM Light. Там этот класс назвали EventToCommand:
    http://blog.galasoft.ch/archive/2009/11/05/mvvm-light-toolkit-v3-alpha-2-eventtocommand-behavior.aspx

    И если мне не изменяет память точно такое же решение есть у Prism.

    Правда меня смущает один момент в этом подходе - слишком уж много структур данных приходится декларировать в Xaml.

    Как ты считаешь, насколько правильно вместо
    <Rectangle>
    <i:Interaction.Triggers>
    <i:EventTrigger EventName="MouseEnter">
    <cmd:EventToCommand Command="{Binding TestCommand}" />
    </i:EventTrigger>
    </i:Interaction.Triggers>
    </Rectangle>

    ...попробовать реализовать более краткую нотацию в духе:
    <Rectangle>
    <i:Interaction.Triggers>
    <cmd:TriggerToCommand EventName="MouseEnter" Command="{Binding TestCommand}" />
    </i:Interaction.Triggers>
    </Rectangle>

    ОтветитьУдалить
  2. Спасибо за комментарий :)!

    Думаю, есть здравый смысл в обоих подходах. В твоем примере, очевидно, повышается читабельность.

    В оригинальном подходе - повышается степень свободы. Т.е. если ты хочешь выполнить не команду а любой другой TrigerAction (или несколько действий к ряду) то всегда пожалуйста... Учитывая, что это Framework, где гибкость крайне важна, а читают XAML только редкие гики (и я :) ), считаю подход Laurent Bugnion'a, автора MVVM Light'a, весьма уместным :).

    ОтветитьУдалить