[Silverlight入门系列]使用MVVM模式(9): 想在ViewModel中控制Storyboard动画?
在前面的Silverlight入门系列文章中穿插讲了一些MVVM模式系列文章,MVVM模式貌似简单,其实要把界面逻辑抽象出来还是很不容易,像《TreeView真正实现MVVM模式和Expanded发生时异步动态加载子节点(WCFRiaService)》就不是这么简单,有的童鞋像feiyang还要实现Treeview的展开状态持久化和自动恢复,配合MVVM实现不容易。所以,MVVM的核心概念理解不难,在具体使用上则问题多多。今天要讲的话题就是一个MVVM使用上的具体问题: Silverlight中的Storyboard动画是否可以在ViewModel中来控制?
为什么想在ViewModel中控制Storyboard?
假设我的业务逻辑在ViewModel中,业务操作好了保存Save成功了就需要启动一个动画:Stobyboard.begin()。而这个动画在视图中,怎么去控制它?这个需求很普遍吧。确实很普遍,但实现就不那么简单了,不像下面这样的Storyboard启动那么简单:
1: <Image x:Name="myImage"
2: Source="http://www.silverlightinaction.com/man.png">
3: <Image.Triggers>
4: <EventTrigger RoutedEvent="Image.Loaded">
5: <BeginStoryboard>
6: <Storyboard x:Name="myStoryboard">
7: <DoubleAnimation Duration="0:0:2"
8: Storyboard.TargetName="myImage"
9: Storyboard.TargetProperty="Opacity"
10: From="0" To="1" />
11: </Storyboard>
12: </BeginStoryboard>
13: </EventTrigger>
14: </Image.Triggers>
15: </Image>
解决方法一:ViewModel中用事件Event通知View启动Storyboard动画
ViewModel是对界面逻辑、业务逻辑、和模型数据的封装和抽象,ViewModel不依赖于具体的View视图,所以ViewModel根本不知道具体的某个Storyboard,怎么去启动这个动画呢? 解决问题思路有好多:第一种方法就是很自然的想到在ViewModel中用事件Event通知View启动动画。具体做法是:在ViewModel中添加一个事件Event,当业务操作好了保存Save成功了用这个事件通知View,这样View在Event的处理函数里面打开动画即可。
ViewModel代码:
1: public class YourViewModel
2: {
3: public delegate void YourEventHandler(object sender, EventArgs e);
4: public event YourEventHandler YourEvent;
5: protected void OnYourEvent(EventArgs e) {
6: if (YourEvent != null) YourEvent(this, e);
7: }
8:
9: //当业务操作好了保存Save成功了触发这个事件
10: //OnYourEvent(new EventArgs(a));
11: }
在Xaml.cs写code behind代码:
1: var vm = new YourViewModel();
2: vm.YourEvent += (s,e) =>
3: {
4: var story = Resources["YourTransition"] as Storyboard;
5: story.Begin();
6: };
7: this.DataContext = vm;
解决方法二:ViewModel属性和View绑定并用Trigger
大家知道,ViewModel的属性可以和View绑定,当属性变化的时候用NotifyPropertyChanged自动通知View。按照这个思路,我们只要在ViewModel加一个属性,当业务操作好了保存Save成功了就改变这个属性的值,然后就会自动通知View,在View中加个Trigger,当绑定的值变化的时候就触发启动动画。
假设ViewModel属性为bool PopupSideShow. 在视图中:
1: xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
Storyboard定义在Resource中:(此处Storyboard没实际意义仅为演示)
1: <UserControl.Resources>
2: <Storyboard x:Name="popupSideShowStory">
3: <DoubleAnimation Storyboard.TargetName="PopUpDisplaySide" Storyboard.TargetProperty="Height" From="0" To="{Binding _PopupSideHeight}" Duration="0:0:1" />
4: <DoubleAnimation Storyboard.TargetName="PopUpDisplaySide" Storyboard.TargetProperty="Width" From="0" To="{Binding _PopupSideWidth}" Duration="0:0:1" />
5: </Storyboard>
6: <Storyboard x:Name="popupSideHideStory">
7: <DoubleAnimation Storyboard.TargetName="PopUpDisplaySide" Storyboard.TargetProperty="Height" From="{Binding _PopupSideHeight}" To="0" Duration="0:0:.8" />
8: <DoubleAnimation Storyboard.TargetName="PopUpDisplaySide" Storyboard.TargetProperty="Width" From="{Binding _PopupSideWidth}" To="0" Duration="0:0:.8" />
9: </Storyboard>
10: </UserControl.Resources>
在View视图中绑定ViewModel属性为bool PopupSideShow,并用Trigger实现当绑定的值
PopupSideShow
变化的时候就触发启动动画:
1: <Grid x:Name="PopUpDisplaySide" Background="White">
2: <i:Interaction.Triggers>
3: <ei:DataTrigger Binding="{Binding PopupSideShow}" Value="true">
4: <ei:ControlStoryboardAction Storyboard="{StaticResource popupSideShowStory}"/>
5: </ei:DataTrigger>
6: <ei:DataTrigger Binding="{Binding PopupSideShow}" Value="false">
7: <ei:ControlStoryboardAction Storyboard="{StaticResource popupSideHideStory}"/>
8: </ei:DataTrigger>
9: </i:Interaction.Triggers>
10: <StackPanel Orientation="Vertical">
11: <TextBlock Text="{Binding _PopupTitle}" FontSize="16" />
12: </StackPanel>
13: </Grid>
解决方法三:加一个中间人管理Storyboard从而既实现ViewModel和View解耦,又能在ViewModel控制StoryboardViewModel属性和View
既然我们想在ViewModel里面控制Storyboard,而ViewModel又不能依赖具体的View,所以我们可以加个中间人把Storyboard抽象出来,这样既能实现ViewModel和View解耦,又能在ViewModel通过中间人控制Storyboard。这个思路我想也是很自然的。但怎么实现呢?首先这个中间人要和View发生联系必须要能在Xaml里面绑定,所以我们要实现DependencyProperty。
我们首先加一个StoryboardManager:
1: using System;
2: using System.Windows;
3: using System.Windows.Media.Animation;
4: using System.Collections.Generic;
5:
6: namespace TestVMAnimation
7: {
8: public class StoryboardManager
9: {
10: public static DependencyProperty IDProperty =
11: DependencyProperty.RegisterAttached("ID", typeof(string), typeof(StoryboardManager),
12: new PropertyMetadata(null, IdChanged));
13:
14: static readonly Dictionary<string, Storyboard> Storyboards = new Dictionary<string, Storyboard>();
15:
16: public delegate void Callback(object state);
17:
18: /// <summary>
19: /// IDs the changed.
20: /// </summary>
21: /// <param name="obj">The obj.</param>
22: /// <param name="e">The <see cref="System.Windows.DependencyPropertyChangedEventArgs"/> instance containing the event data.</param>
23: private static void IdChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
24: {
25: var sb = obj as Storyboard;
26: if (sb == null)
27: return;
28:
29: var key = e.NewValue as string;
30: if (Storyboards.ContainsKey(key))
31: Storyboards[key] = sb;
32: else
33: Storyboards.Add(key, sb);
34: }
35:
36: /// <summary>
37: /// Plays the storyboard.
38: /// </summary>
39: /// <param name="id">The id.</param>
40: /// <param name="callback">The callback.</param>
41: /// <param name="state">The state.</param>
42: public static void PlayStoryboard(string id, Callback callback, object state)
43: {
44: if (!Storyboards.ContainsKey(id))
45: {
46: callback(state);
47: return;
48: }
49: Storyboard sb = Storyboards[id];
50: EventHandler handler = null;
51: EventHandler handlertemp = handler;
52: handler = delegate { sb.Completed -= handlertemp; callback(state); };
53: sb.Completed += handler;
54: sb.Begin();
55: }
56:
57: /// <summary>
58: /// Sets the ID.
59: /// </summary>
60: /// <param name="obj">The obj.</param>
61: /// <param name="id">The id.</param>
62: public static void SetID(DependencyObject obj, string id)
63: {
64: obj.SetValue(IDProperty, id);
65: }
66:
67: /// <summary>
68: /// Gets the ID.
69: /// </summary>
70: /// <param name="obj">The obj.</param>
71: /// <returns></returns>
72: public static string GetID(DependencyObject obj)
73: {
74: return obj.GetValue(IDProperty) as string;
75: }
76: }
77:
78: }
有了DependencyProperty就可以在Xaml里面绑定了,注意下面的StoryboardManager.ID:
1: <UserControl.Resources>
2: <Storyboard x:Key="YourStoryboardResourceKey"
3: StoryboardManager:StoryboardManager.ID="YourAnimation">
4: <DoubleAnimation By="360" Duration="0:0:1" Storyboard.TargetName="btn1"
5: Storyboard.TargetProperty="Angle" />
6: </Storyboard>
7: </UserControl.Resources>
在ViewModel里面控制Storyboard很简单,下面这个例子是通过Command调用的,你当然也可以不通过Command直接调用Storyboard,像本文的例子,可以在ViewModel的业务逻辑里面当业务操作好了保存Save成功了启动Storyboard动画。
1: public class YourViewModel
2: {
3: public ICommand PlayStoryboardCommand { get; private set; }
4:
5: public YourViewModel()
6: {
7: PlayStoryboardCommand = new DelegateCommand(
8: () => {
9: StoryboardManager.PlayStoryboard("YourAnimation", (o) => { }, null);
10: });
11: }
12:
13:
14: }
解决方法四:不要在ViewModel里面控制Storyboard,把Transition封装在控件中
用MVVM模式的出发点之一就是分离关注点(Separation of concerns). View负责什么?UI Layout, structure, appearance,animation, 那View的CodeBehind(Xaml.cs)可以有什么?View的Code Behind可以有Initialize Component, 可以有Xaml里面表示不了的视觉行为,比如复杂动画控制(带callback,completed事件那种)。还可以是视觉元素的控制。总之,只要这些代码是View该负责的,是高内聚的,是不想被重用的,是不能被测试的,那你就搁在code behind好了。绝对应该避免业务逻辑在里面哦。某位大神说过,“解决问题的最好办法是think different,说不定问题本身就不是个问题”。是的,你想在ViewModel里面控制Storyboard,这本身是不是有问题? 想想我们的动画一般在什么时候发生?真的是业务逻辑完成了发生吗?真的和业务逻辑相关吗?不!动画其实是和VisualElement的VisualState相关。也就是说,我们往往是在某个panel显示/隐藏/打开/关闭的时候有个淡入淡出、推箱子、跳跃、或者x/y/z/3D旋转的效果(不要告诉我是显示/隐藏panel本身,这个可以和ViewModel的属性绑定的,不是动画)。说白了就是一个transition,从一个VisualState到另一个VisualState而已。好了,想清楚了,问题就没有了。也就是说,你无须在ViewModel里面控制Storyboard,只要在View里面定义好VisualState就可以了,封装在控件行为中,把VisualState动画写在控件的模板中,有关怎么封装Silverlight控件这儿就不多说了,下回有空再说。具体做法可以参考MSDN这个页面,里面就有button的VisualState切换动画,比如MouseOver等:
1: <Style TargetType="Button">
2: <Setter Property="Background" Value="#FF1F3B53"/>
3: <Setter Property="Foreground" Value="#FF000000"/>
4: <Setter Property="Padding" Value="3"/>
5: <Setter Property="BorderThickness" Value="1"/>
6: <Setter Property="BorderBrush">
7: <Setter.Value>
8: <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
9: <GradientStop Color="#FFA3AEB9" Offset="0"/>
10: <GradientStop Color="#FF8399A9" Offset="0.375"/>
11: <GradientStop Color="#FF718597" Offset="0.375"/>
12: <GradientStop Color="#FF617584" Offset="1"/>
13: </LinearGradientBrush>
14: </Setter.Value>
15: </Setter>
16: <Setter Property="Template">
17: <Setter.Value>
18: <ControlTemplate TargetType="Button">
19: <Grid>
20: <vsm:VisualStateManager.VisualStateGroups>
21: <vsm:VisualStateGroup x:Name="CommonStates">
22: <vsm:VisualState x:Name="Normal"/>
23: <vsm:VisualState x:Name="MouseOver">
24: <Storyboard>
25: <DoubleAnimation Duration="0" Storyboard.TargetName="BackgroundAnimation" Storyboard.TargetProperty="Opacity" To="1"/>
26: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[1].(GradientStop.Color)" To="#F2FFFFFF"/>
27: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[2].(GradientStop.Color)" To="#CCFFFFFF"/>
28: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[3].(GradientStop.Color)" To="#7FFFFFFF"/>
29: </Storyboard>
30: </vsm:VisualState>
31: <vsm:VisualState x:Name="Pressed">
32: <Storyboard>
33: <ColorAnimation Duration="0" Storyboard.TargetName="Background" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="#FF6DBDD1"/>
34: <DoubleAnimation Duration="0" Storyboard.TargetName="BackgroundAnimation" Storyboard.TargetProperty="Opacity" To="1"/>
35: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[0].(GradientStop.Color)" To="#D8FFFFFF"/>
36: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[1].(GradientStop.Color)" To="#C6FFFFFF"/>
37: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[2].(GradientStop.Color)" To="#8CFFFFFF"/>
38: <ColorAnimation Duration="0" Storyboard.TargetName="BackgroundGradient" Storyboard.TargetProperty="(Rectangle.Fill).(GradientBrush.GradientStops)[3].(GradientStop.Color)" To="#3FFFFFFF"/>
39: </Storyboard>
40: </vsm:VisualState>
41: <vsm:VisualState x:Name="Disabled">
42: <Storyboard>
43: <DoubleAnimation Duration="0" Storyboard.TargetName="DisabledVisualElement" Storyboard.TargetProperty="Opacity" To=".55"/>
44: </Storyboard>
45: </vsm:VisualState>
46: </vsm:VisualStateGroup>
47: <vsm:VisualStateGroup x:Name="FocusStates">
48: <vsm:VisualState x:Name="Focused">
49: <Storyboard>
50: <DoubleAnimation Duration="0" Storyboard.TargetName="FocusVisualElement" Storyboard.TargetProperty="Opacity" To="1"/>
51: </Storyboard>
52: </vsm:VisualState>
53: <vsm:VisualState x:Name="Unfocused" />
54: </vsm:VisualStateGroup>
55: </vsm:VisualStateManager.VisualStateGroups>
56: <Border x:Name="Background" CornerRadius="3" Background="White" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}">
57: <Grid Background="{TemplateBinding Background}" Margin="1">
58: <Border Opacity="0" x:Name="BackgroundAnimation" Background="#FF448DCA" />
59: <Rectangle x:Name="BackgroundGradient" >
60: <Rectangle.Fill>
61: <LinearGradientBrush StartPoint=".7,0" EndPoint=".7,1">
62: <GradientStop Color="#FFFFFFFF" Offset="0" />
63: <GradientStop Color="#F9FFFFFF" Offset="0.375" />
64: <GradientStop Color="#E5FFFFFF" Offset="0.625" />
65: <GradientStop Color="#C6FFFFFF" Offset="1" />
66: </LinearGradientBrush>
67: </Rectangle.Fill>
68: </Rectangle>
69: </Grid>
70: </Border>
71: <ContentPresenter
72: x:Name="contentPresenter"
73: Content="{TemplateBinding Content}"
74: ContentTemplate="{TemplateBinding ContentTemplate}"
75: VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
76: HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
77: Margin="{TemplateBinding Padding}"/>
78: <Rectangle x:Name="DisabledVisualElement" RadiusX="3" RadiusY="3" Fill="#FFFFFFFF" Opacity="0" IsHitTestVisible="false" />
79: <Rectangle x:Name="FocusVisualElement" RadiusX="2" RadiusY="2" Margin="1" Stroke="#FF6DBDD1" StrokeThickness="1" Opacity="0" IsHitTestVisible="false" />
80: </Grid>
81: </ControlTemplate>
82: </Setter.Value>
83: </Setter>
84: </Style>
解决方法五:把Storyboard作为ViewModel的一个属性给View来绑定(糟糕的主意)
也许有人会想到这个主意:在ViewModel中加个Storyboard类型的属性,给view绑定传进去,这样在ViewModel的业务逻辑中当业务操作好了保存Save成功了就可以直接调用自己的Storyboard.begin(),岂不爽哉?我想说这是个糟糕的主意,为什么?不要把业务逻辑无关的纯UI的元素混到viewModel里面,难道要抽象依赖于具体?
解决方法六:用VisualStateManager,在ViewModel用事件通知View(仅供参考)
用VisualStateManager的方法(Event同方法一的事件),在视图收到事件通知以后,调用StateManager启动动画而已。在xaml.cs中:
1:
2: VisualStateManager.GoToState(this, "YourState1", true);
3:
在Xaml中把动画不要定义在Resource中,而是定义为几个VisualState:
1: xmlns:ic="clr-namespace:Microsoft.Expression.Interactivity.Core;assembly=Microsoft.Expression.Interactions"
2:
3: <Grid x:Name="LayoutRoot">
4: <VisualStateManager.CustomVisualStateManager>
5: <ic:ExtendedVisualStateManager/>
6: </VisualStateManager.CustomVisualStateManager>
7: <VisualStateManager.VisualStateGroups>
8: <VisualStateGroup x:Name="EditViewGroup" ic:ExtendedVisualStateManager.UseFluidLayout="True">
9: <VisualStateGroup.Transitions>
10: <VisualTransition GeneratedDuration="00:00:00.2500000"/>
11: </VisualStateGroup.Transitions>
12: <VisualState x:Name="YourState1">
13: <Storyboard>
14: <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.0010000" Storyboard.TargetName="GridFlyout" Storyboard.TargetProperty="(FrameworkElement.Margin)">
15: <DiscreteObjectKeyFrame KeyTime="00:00:00">
16: <DiscreteObjectKeyFrame.Value>
17: <Thickness>0,0,0,-101</Thickness>
18: </DiscreteObjectKeyFrame.Value>
19: </DiscreteObjectKeyFrame>
20: </ObjectAnimationUsingKeyFrames>
21: </Storyboard>
22: </VisualState>
23: <VisualState x:Name="YourState2">
24: <Storyboard>
25: <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.0010000" Storyboard.TargetName="GridFlyout" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[3].(TranslateTransform.X)">
26: <EasingDoubleKeyFrame KeyTime="00:00:00" Value="3"/>
27: </DoubleAnimationUsingKeyFrames>
28: <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.0010000" Storyboard.TargetName="GridFlyout" Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[3].(TranslateTransform.Y)">
29: <EasingDoubleKeyFrame KeyTime="00:00:00" Value="-101"/>
30: </DoubleAnimationUsingKeyFrames>
31: <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.0010000" Storyboard.TargetName="GridFlyout" Storyboard.TargetProperty="(FrameworkElement.Margin)">
32: <DiscreteObjectKeyFrame KeyTime="00:00:00">
33: <DiscreteObjectKeyFrame.Value>
34: <Thickness>-4,0,0,-101</Thickness>
35: </DiscreteObjectKeyFrame.Value>
36: </DiscreteObjectKeyFrame>
37: </ObjectAnimationUsingKeyFrames>
38: </Storyboard>
39: </VisualState>
40: </VisualStateGroup>
41: </VisualStateManager.VisualStateGroups>
42: </Grid>
总结
以上几种方法个人觉得第二种最好,第三种次之,第四种也不错但是比较费时间。我们遇到问题不仅仅是思考问题,解决问题,还要发散思维想想多重解决方案并选择最优最简单的方案;如果当初是赶时间,那后续就需要重构来寻求最优解决方案。这种重构是有意义的。就像我在前一篇中如何在Silverlight页面间传递复杂对象,也给出了5种解决方法,选择最优的一种,好的攻城师应当多钻研,多分享,多接受批评和自我批评,这样才能进步的快一些。