Сегодня мы посмотрим на часть 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().
Итак, мы посмотрели на триггеры: объекты, инициирующие действия, по каким-то условиям. Но что же представляют собой действия?
Когда наступает "тот самый" момент, мы вызываем метод 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.
Поскольку действия имеют смысл только с триггерами, давайте соберем все вместе и посмотрим, на реализацию "Поднять зарплату, когда рак на горе свистнет".
Стоит отметить, что действия могут быть исключены из обработки, установкой свойства 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-разметку:
Запускаем приложение, и после первого же свистка рака наша зарплата повышается. Свисти, дружок, свисти :)!
Стоит обратить внимание на то, что мы можем указать IncreaseSalary выбрать другой TextBlock для сообщения добрых вестей. Достаточно установить ему свойство TargetName. Например, следующий код будет так же прекрасно работать:
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(), вызываемый при срабатывании триггера.
Прежде чем мы перейдем к созданию интересных действий, давайте подведем итоги:
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 кода:
Мы хотим создать действие, которое будет выполнятся в рамках триггера (не поведения), и будет запускать команду с указанным именем, определенную в DataContext'e. Т.е создать аналог следующего WPF кода:
<Button Command={Binding DoSomethingCommand} />
Приступим!
Код C#:
Код 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.
Но почему бы просто не определить свойство типа 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 выглядит так:
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.
С нетерпением буду ждать ваших комментариев и пожеланий :)!
Отличного настроения и программирования, друзья!
Код к статье: здесь.
С нетерпением буду ждать ваших комментариев и пожеланий :)!
Отличного настроения и программирования, друзья!
Код к статье: здесь.
Спасибо за статью.
ОтветитьУдалитьВ последнее время активно занимаюсь решением инфраструктурных проблем в 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>
Спасибо за комментарий :)!
ОтветитьУдалитьДумаю, есть здравый смысл в обоих подходах. В твоем примере, очевидно, повышается читабельность.
В оригинальном подходе - повышается степень свободы. Т.е. если ты хочешь выполнить не команду а любой другой TrigerAction (или несколько действий к ряду) то всегда пожалуйста... Учитывая, что это Framework, где гибкость крайне важна, а читают XAML только редкие гики (и я :) ), считаю подход Laurent Bugnion'a, автора MVVM Light'a, весьма уместным :).