среда, 29 апреля 2009 г.

Magic Shaders: Genie Effect

Привет!

Мой коллега, счастливый обладатель MacBook'a, показал мне сногсшибательный эффект сворачивающегося окошка в Mac OS'e: Genie effect (эффект джина). Вот как он выглядит:



Тогда я загорелся идеей сделать аналогичный эффект в Silverlight приложении, но не знал как :). Последнюю неделю я провел за изучением пиксельных шейдеров и к огромной моей радости обнаружил ключ к разгадке. В скором времени родилась библиотека, реализующая Genie-эффект, как в WPF'e, так и в Silverlight'e.  Посмотреть живую демку эффекта можно здесь (нужен 3-й сильверлайт: link). В этом посте мы:
  • Посмотрим как использовать Genie-эффект у себя в коде.
  • Посмотрим как создаются собственные эффекты на базе пиксельных шейдеров.
  • Разберем алгоритм Genie-эффекта
Ссылка на исходники к статье находится в конце поста. Исходники распространяются под LGPL лицензией.

NB: Apple Inc. является обладателем патента на этот эффект в США. Надеюсь, я не нарушаю их прав, описывая в исключительно учебных целях один из возможных вариантов создания подобного эффекта на WPF/Silverlight технологиях.

GenieLib в действии


GenieLib - это моя библиотека, реализующая описанный эффект Джина (исходники - в конце поста). Для того, чтобы создать Genie-эффект в вашем приложении достаточно добавить ссылку на сборку GenieLib и определить два UIElement'a: один будет представлять Джина, а второй - Лампу (место, куда прячется Джин).

Вот, как это выглядит в коде:

XAML:
<Button Click="ToggleGenie"
 x:Name="btnLamp"
        Content="Lamp"/>
<Image x:Name="imgGenie" Source="...">
<Image.RenderTransform>
    <TranslateTransform />
</Image.RenderTransform>
</Image>

C#
private void ToggleGenie()
{
  GenieManager.IsGenieOut = !GenieManager.IsGenieOut;
}

private Magic GenieManager
{
  get
  {
    if (_genieManager == null)
    {
      _genieManager = new Magic(btnLamp, imgGenie);
      _genieManager.Expanding +=
        (o, e) =>
        {
          imgGenie.Visibility = Visibility.Visible;
        };

      _genieManager.Collapsed +=
        (o, e) =>
        {
          imgGenie.Visibility = Visibility.Collapsed;
        };
    }
    return _genieManager;
  }
}

Как видите, мы определили два UIElement'a в xaml-файле, и по нажатию на кнопку, которая по совместительству подрабатывает волшебной лампой, мы обращаемся к объекту типа Magic, инвертируя текущее состояние Джина. Не смотря на название, класс Магии не делает ничего таинственного. В его обязанности входит построение анимации перехода Джина в Лампу и из нее. Класс не накладывает никаких ограничений на представителей Лампы и Джина. Но для правильной работы он ожидает от Джина наличия одного RenderTransform'a: TranslateTransform. Именно этот трансформ позволяет Джину "летать" в пространстве по направлению к лампе и от нее. Если это требование не соблюдается, Джин будет уменьшаться в рамках собственной области, не приближаясь к лампе (картинка слева):



Размер и позиция Лампы относительно Джина играют важную роль. Позиция определяет в какую сторону будет лететь Джин при сворачивании, а размер Лампы (ширина или высота, в зависимости от направления сворачивания) указывает Джину насколько сильно нужно сузиться, чтобы "влезть" в горлышко Лампы :).

Помимо этого класс Magic уведомляет клиентов  при помощи событий о специфических состояниях Джина (собирается спрятаться в лампу, спрятался, собирается выйти из лампы и вышел из лампы). Клиенты могут реагировать на события должным образом. В нашем случае, мы убираем Джина, устанавливая его свойство Visibility в Visibility.Collapsed, когда Джин спрятался в Лампу, и показываем его, когда Джин собирается наружу. Забегая вперед, скажу, что эффект, который реализует уменьшение Джина ничего не знает о HitTesting'e, поэтому мы должны позаботиться об этом самостоятельно. Иными словами, если бы мы не меняли видимость Джина, пользователь все так же мог бы кликнуть мышью по пустой области и попасть на невидимую кнопку спрятавшегося Джина.

Библиотека также поддерживает несколько "продвинутых" сценариев, но я не хочу утомлять вас, дорогой читатель, этими подробностями. Любопытные всегда смогут найти детали в коде, который я старался тщательно документировать и писать предельно ясно :). Дальше мы переходим к промежуточному шагу: создание эффектов при помощи пиксельных шейдеров.


Создание эффектов при помощи шейдеров


Третий Silverlight ввел поддержку эффектов, которые сыздавна были доступны разработчикам WPF-приложений. Теперь мы можем добавить любому  UIElement'у эффект тени (DropShadowEffect), или сделать его размытым (BlurEffect). Но как быть, если мы хотим создать свой собственный эффект, например, полностью убирающий красную составляющую из UIElement'a к которому он прикреплен? На помощь приходит класс ShaderEffect, который также был доступен WPF разработчикам с выходом WPF 3.5 SP1.

Суть ShaderEffect'a проста: взять визуальное представление элемента и прогнать его через пиксельный шейдер. Мое наивное определение пиксельного шейдера: это функция, возвращающая цвет точки в заданной координате. Правда, функция эта описывается не на C#, а на языке шейдеров, и исполняется обычно не на CPU а на GPU. Мы пользуемся С-подобным языком от Microsfot'a: HLSL (High Level Shaders Language - высокоуровневым языком шейдеров) для описания шейдера. В Silvelright 3 Beta 1 сам шейдер выполняется центральным процессором, не смотря на введенную поддержку GPU. В релиз версию парни из Майкрософт обещают включить поддержку выполнения шейдеров видео картой.

Алгоритм создания ShaderEffect'a:

1. Создаем файл с названием RemoveRed.fx. В файле определяем функцию шейдера:
float r : register(C0);

sampler2D implicitInputSampler : register(S0);

float4 main(float2 uv : TEXCOORD) : COLOR
{
   float4 c = tex2D( implicitInputSampler, uv );
   c.r = r;
   return c;
} 
Здесь мы определили две глобальные переменные (r типа float; implicitInputSampler типа sampler2D) и одну функцию main(). На входе функция принимает переменную типа float2 - двумерный вектор, представляющий координаты точки, для которой требуется вычислить цвет. Выходом из функции служит переменная типа float4 - вектор с четырьмя измерениями, описывающий цвет точки (r, g, b, a). Значения координаты точки меняется в пределах от 0 до 1. Точка с координатами (0,0) - верхний левый угол, (1, 1) - нижний правый. Аналогичным диапазоном измеряется и составляющие цвета: от нуля до единицы.

Переменная типа sampler2D предоставляет механизм доступа к цветам элемента, к которому мы применяем эффект. Для того чтобы узнать какого цвета точка, скажем в правом верхнем углу достаточно вызвать встроенную функцию tex2D(implicitInputSampler, (1, 0)), которая вернет нам вектор со значениями цвета: (r, g, b, a). В примере выше, мы просто получаем истинный цвет точки в своей собственной позиции и изменяем красную составляющую на значение глобальной переменной r. Так, если значение r будет равно нулю мы получим следующее преобразование:


Значения глобальных переменных мы можем установить извне шейдера. Как именно - описано ниже. На этом мы закончим обзор HLSL и двинемся дальше, но пытливый читатель всегда найдет много полезной и интересной информации в разделах MSDN'a, посвященных языку программирования шейдеров.

2. Далее, мы компилируем эффект при помощи утилиты fxc.exe из DirectX SDK.. Просто запускаем компилятор из командной строки:

   fxc.exe /T ps_2_0 /E main /Fo RemoveRed.ps RemoveRed.fx
Здесь мы явно указываем компилятору использовать вторую модель пиксельных шейдеров (/T ps_2_0), в качестве входа в программу шейдера служит функция main (/E main), и, ожидаемым результатом компиляции файла RemoveRed.fx является объектный файл RemoveRed.ps (/Fo RemoveRed.ps RemoveRed.fx). Если у вас нет желания устанавливать дополнительно ПО, всегда можно воспользоваться онлайн компилятором:

3. Добавляем скомпилированный файл шейдеров в наш проект. Устанавливаем его Build Action в Resource:


4.Теперь создаем C# класс, который будет представлять наш шейдер в качестве эффекта:

using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Effects;

namespace SilverShaderExample
{
 public class RemoveRedEffect : ShaderEffect
 {
  public static DependencyProperty InputProperty =
   RegisterPixelShaderSamplerProperty("Input",
                                      typeof(RemoveRedEffect), 0);

  public static DependencyProperty RProperty =
   DependencyProperty.Register("R",
                               typeof(double),
                               typeof(RemoveRedEffect),
                               new PropertyMetadata(new double(), PixelShaderConstantCallback(0))
    );

  public RemoveRedEffect()
  {
   var u = new Uri(@"SilverShaderExample;Component/RemoveRed.ps", UriKind.Relative);
   PixelShader = new PixelShader { UriSource = u };

   // Инициализируем свойства, чтобы они получили верные начальные значения.
   UpdateShaderValue(InputProperty);
   UpdateShaderValue(RProperty);
  }

  public virtual Brush Input
  {
   get
   {
    return ((Brush)(GetValue(InputProperty)));
   }
   set
   {
    SetValue(InputProperty, value);
   }
  }

  public virtual double R
  {
   get
   {
    return ((double)(GetValue(RProperty)));
   }
   set
   {
    SetValue(RProperty, value);
   }
  }
 }
}
  

Здесь основной акцент стоит сделать на конструкторе класса и на регистрации Dependency свойств.

Давайте на секунду обратимся к .fx файлу. Помните наши глобальные переменные в шейдере? Так вот, при помощи Dependency-свойств, именно здесь происходит связывание переменных шейдера со свойствами managed-объекта. Сэмплер регистрируется при помощи метода RegisterPixelShaderSamplerProperty(), а переменная R использует PixelShaderConstantCallback() в качестве метода, реагирующего на изменения.

В конструкторе класса мы создаем ссылку на скомпилированный шейдер. Стоит быть очень внимательным в формировании этой ссылки. Например, если вы укажете Uri("RemoveRed.ps", UriKind.Relative) вместо Uri(@"SilverShaderExample;Component/RemoveRed.ps", UriKind.Relative), то сильверлайт упадет с далеко не очевидным объяснением исключения. Затем  мы создаем новый экземпляр класса PixelShader, который представляет собой управляемую обертку над нашим шейдером.

Поскольку переменные шейдера и managed-объекта связываются при помощи dependency-свойств, мы можем делать с переменными шейдерами все тоже, что и с обычными dependency-свойствами (анимировать, привязывать к данным, использовать в триггерах и т.д.).

Дальше дело за малым: применить эффект можно к любому UIElement'у. Так, следующий код, определяющий Grid с красным фоном будет отображен как черный прямоугольник, благодаря нашему  эффекту:

RemoveRed
В Silverlight 3.0 beta 1 можно применить только один эффект к одному элементу. Но элемент всегда можно вложить в несколько, скажем, Border'ов, и применить по одному из эффектов к каждому Border'у :).

Завершая наш обзор шейдерных эффектов, привожу ссылку на инструмент, значительно упрощающий работу: Shazzam Tool (click-once installer), Shazzam Help (Silverlight-based help). Shazzam позволяет отредактировать HLSL код:



Скомпилировать HLSL-код, посмотреть на результат и на сгенерированный C#- или VB-код для эффекта, не отходя от кассы (WYSIWYG, по-нашему):


Более того, если эффект содержит входящие параметры, их можно модифицировать прямо из Shazzam'a:

Вобщем, must-have, если вы серьезно настроены на создание своих эффектов :).

Алгоритм Genie


Здесь мы посмотрим как заставить Джина "свернуться" вниз. Сворачивание вверх, влево и вправо получается аналогично.

В основе эффекта лежит простая идея: за время t нужно свернуть картинку в точку (а точнее, в отрезок). При t = 0 картинка полностью развернута, а при t = 1 - полностью свернута. Как вы помните, функция пиксельного шейдера возвращает значение цвета точки. Т.е.нам нужно определить цвет заданной точки, с учетом текущего времени t. Это приводит нас к следующему коду:

float t : register(c0); // Время

sampler2D implicitInput : register(s0); // сэмплер

float4 main(float2 uv : TEXCOORD) : COLOR
{
 // [.. вычисление x1, y1 x2, y2 ..]
 
 if (uv.x < x1 || uv.x > x2 || uv.y < y1 || uv.y >y2)
 {
  return float4(0, 0, 0, 0);
 }
  
 float2 pos = float2((uv.x - x1)/(x2 - x1), (uv.y - y1)/(y2 - y1));
  
 return tex2D(implicitInput, pos);
}

Мы проверяем, или выходит текущая точка uv за пределы области (x1, y1, x2, y2). Если да - возвращаем прозрачный цвет (r = 0, g = 0, b = 0, a = 0), если же нет, то мы должны вернуть такой цвет, чтобы создался эффект "сжатия". Для этого мы отображаем ограничивающую нас область на всю картинку, т.е. (x1, x2) -> (0, 1), и (y1, y2) -> (0, 1). Что дает нам необходимые координаты: x = (uv.x - x1)/(x2 - x1), y = (uv.y - y1)/(y2 - y1).

Осталось посчитать x1, x2, y1, y2. Начальные значения переменных устанавливаются таким образом, чтобы вся картинка была видимой: x1 = 0, y1 = 0, x2 = 1, y2 = 1.Теперь мы вводим зависимость этих переменных от времени. Если, например, y1 будет увеличиваться пропорционально времени t: y1 = t, мы получим сжатие по вертикали:


Устанавливая более сложные зависимости, мы получаем Genie-эффект. Эффект состоит из двух фаз. Фаза сжатия по оси Х длится 40% времени:

Фаза сжатия по оси Y (оставшиеся 60%):

Для вычисления x1 и x2  я воспользовался кубическими кривыми Бизье. Одна кривая определяется четырьмя опорными точками P0, P1, P2 и P3. Кривая начинается в P0 и заканчивается в P3. Внутренние точки P1 и P2 управляют формой кривой и определяют начальный и конечный векторы касательных. Другими словами, кривая Бизье третьего порядка начинается в точке P0 направляясь к точке P1 и заканчивается в P3, подходя к ней со стороны точки P2:

Для нас началом кривой всегда является P0 = 0, а конец зависит от времени t, центра лампы и ширины ее "горлышка".  С учетом двухфазового подхода к эффекту, мы получаем следующий HLSL код:
float t : register(c0); // Время
float lampPosition : register(c1); // Центр Лампы
float lampWidth: register(c2); // Ширина Горлышка Лампы

sampler2D implicitInput : register(s0); // Сэмплер

// Параметрическая запись кубической кривой Бизье
// честно стибрена с http://ru.wikipedia.org/wiki/Кривая_Безье
float BezierInterpolate(float4 cp, float t)
{
     float b0 = pow(1-t, 3);
     float b1 = 3*t*pow(1-t,2);
     float b2 = 3*t*t*(1-t);
     float b3 = pow(t, 3);
     return b0*cp[0] + b1*cp[1] + b2*cp[2] + b3*cp[3];
}

float4 main(float2 uv : TEXCOORD) : COLOR
{
     float x1=0, x2=1, y1=0, y2 = 1;
     float4 lp, rp; // Опорные точки левой и правой кривых Бизье
     
     // Фаза сжатия по оси Y
     if (t > 0.4) 
     {
      lp[3] = lampPosition - lampWidth/2;
      rp[3] = lampPosition + lampWidth/2;
      y1 = (5*t - 2)/3.0;
     }
     else // Сжимаемся по оси X.
     {
      lp[3] = t*(lampPosition - lampWidth/2)/0.4;
      rp[3] = 1 + t*(lampPosition + lampWidth/2 - 1.0)/0.4;
      y1 = 0;
     }
     
     // Подсчитываем значения осташихся опорных точек
     // кривых Бизье.
     lp[2] = 0.90*lp[3];
     lp[1] = 0.25*lp[3];
     lp[0] = 0;
     
     rp[2] = 1.25*rp[3];
     rp[1] = 1-(rp[3] - rp[2] - lampWidth/2);
     rp[0] = 1;
     
     lp = saturate(lp); 
     rp = saturate(rp);

     // Вычисляем значение границ по оси X.
     x1 = BezierInterpolate(lp, uv.y);
     x2 = BezierInterpolate(rp, uv.y);
     
     if (uv.x < x1 || uv.x > x2 || uv.y < y1 || uv.y >y2)
     {
          return float4(0, 0, 0, 0);
     }
     
     float2 pos = float2((uv.x - x1)/(x2 - x1), (uv.y - y1)/(y2 - y1));
     
     return tex2D(implicitInput, pos);
}

Единственной темной лошадкой остался вызов HLSL-функции saturate(), применительно к опорным точкам кривых Бизье. Суть этого метода в отбрасывании точек за границей отрезка [0, 1]:
function saturate(x)
{
  if (x < 0) return 0;
  if (x > 1) return 1;
  return x;
}

Очень удобно, для проверки выхода за допустимые границы.

Вот, собственно, и вся магия :).


Что Дальше?


Итак, вы дочитали до самого конца. Спасибо, мне очень приятно :)! Напоследок могу предложить пару ресурсов, продолжающих тему эффектов и шейдеров:

Код к статье можно скачать здесь. Буду рад вашим комментариям.

Отличного настроения :)!

вторник, 7 апреля 2009 г.

UX Patterns

Привет!

Наткнулся на очень интересное собрание UX Pattern'ов (user experience patterns), от Infragistics. Сайт написан на Silverlight'e, что не может не радовать. Содержит детальное описание для каждого паттерна (в чем проблема, какое решение, в каком контексте используется, примеры). Можно так же смотреть связанные паттерны.

Вот несколько скринов (кликабельно):



Более того, можно предлагать свои находки. Молодцы, Infragistics. Молодцы!

понедельник, 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.

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

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

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