WPF MVVM UI分离之《交互与数据分离》
在我们使用WPF过程中,不可避免并且超级喜欢使用MVVM框架。
那么,使用MVVM的出发点是视觉与业务逻辑分离,即UI与数据分离
诸如下面的问题:
删除操作,假如需要先执行一部分数据的处理,然后删除界面列表中的子项,之后再执行其它数据的处理。请问此业务该放置于Xaml.cs文件,还是ViewModel中呢?
再如弹窗,提示框,设置列表的滚动等等。
此上一些操作,我们不应该把业务代码直接挪到cs文件中,因为删除操作绝大部分的代码都是数据的处理。所以,数据的部分放置在ViewModel中,一些交互放在cs文件中,就是很合理及有必要了。
单元测试,UI与交互的那部分mock模拟有点难度,也没必要去模拟。那么,我们是应该把数据与交互拆开,减少之间的耦合性。这样添加单元测试则更容易。
交互与数据分离 - 描述
首先MVVM,通过View与ViewModel的绑定,我们实现了UI与业务逻辑的分离。通俗一点,我们熟悉的Xaml与ViewModel文件中,代码之间的隔离。在此不详述~
而MVVM,不只是界面与逻辑,其实逻辑还可以拆分成交互与数据
即:Xaml 》Xaml.cs 》ViewModel
是的,按照上面的结构图,我们分成三部分:
- 界面 用于界面呈现 ---- 如页面/控件/样式/模板等其它资源的初始化,动画的触发等。
- 交互 用于与用户确认的交互或者界面复杂逻辑的处理 ---- 如弹窗/提示框/复杂动画的处理/设置列表的滚动等其它界面元素的视觉处理。
- 数据 只是数据的处理 ---- 增删改查导入导出保存等只针对数据的操作,界面状态属性的保存与触发更改(绑定)。
交互与数据分离是怎样的?比如删除:
1. 界面删除按钮,绑定ViewModel中的DeleteCommand,当我们点击删除时,触发DeleteCommand.Execute
2. ViewModel中,先执行数据状态的判断,然后执行交互通知ShowDeleteWaringAction,调用xaml.cs文件中的确认提示框
3. 在Xaml.cs中添加依赖属性ShowDeleteWaring,绑定ViewModel中的ShowDeleteWaringAction.Progress。在属性更改中,处理提示框确认逻辑。
4. ViewModel中,等待ShowDeleteWaring弹框完成后,继续执行下面的业务。
5. 还有类似上面步骤的删除动画。。。
交互与数据分离 - 实现
使用场景:在WPF框架下开发时,一种基于MVVM的UI分离方案
解决方案:在业务逻辑处理过程中,新建一个交互处理线程,通知界面完成交互处理,同时后台逻辑保持同步等待。界面完成交互处理后,回调并执行后续的业务逻辑。
实现方案:
- View中的依赖属性DependencyProperty,绑定ViewModel中属性“UIDelegateOperation”中的交互处理进度“UIDelegateProress”
- 每次在ViewModel执行业务逻辑需要调用交互处理时,由UIDelegateOperation创建一个新的交互进度“UIDelegateProress”,触发属性变更,并设置“UIDelegateOperation”同步等待。
- 当View中的属性变更事件执行完成后,回调并唤醒”UIDelegateOperation“,继续完成后面的业务逻辑。
1. 界面
在Xaml中添加附加属性,删除动画DeleteCoursewaresAnimation,删除确认框ShowDeleteWaring。并绑定ViewModel中对应的属性
1 <UserControl.Style> 2 <Style TargetType="editing:CloudListView"> 3 <Setter Property="DeleteCoursewaresAnimation" Value="{Binding DeleteCoursewaresAnimation.DelegateProgress}" /> 4 <Setter Property="ShowDeleteWaringShow" Value="{Binding ShowDeleteWaring.DelegateProgress}" /> 5 </Style> 6 </UserControl.Style>
界面ListBox,列表子项ListBoxItemr的DataTemplate模板中,删除按钮绑定ViewModel中的DeleteCommand
1 <Button x:Name="DeleteButton" 2 Command="{Binding ElementName=TheCloudDocsList,Path=DataContext.DeleteCommand}" 3 CommandParameter="{Binding RelativeSource={RelativeSource TemplatedParent},Path=DataContext }" 4 Content="删除" Style="{StaticResource Style.Button}" />
2. ViewModel
ViewModel调用UIDelegateOperation交互处理时,根据是否需要同步等待,调用不同的函数 Start(),StartAsync(),StartWithResult(),StartWithResultAsync();
删除业务中,除了数据处理,还有俩个交互(删除确认框,删除元素动画)。
通过在同步调用删除确认框/删除元素动画后,再继续往下执行业务。
属性和字段字义:
定义命令
自定义命令,可以详细之前写的博客:
1 private DelegateCommand<CoursewareListItem> _deleteCommand = null; 2 /// <summary> 3 /// 删除 4 /// </summary> 5 public DelegateCommand<CoursewareListItem> DeleteCommand 6 { 7 get 8 { 9 if (_deleteCommand == null) 10 { 11 _deleteCommand = new DelegateCommand<CoursewareListItem>(DeleteCourseware_OnExecute); 12 13 } 14 return _deleteCommand; 15 } 16 }
提示框确认交互/删除动画交互
1 /// <summary> 2 /// 弹出删除确认窗口 3 /// </summary> 4 public IUIDelegateOperation<List<CoursewareListItem>, MessageResult> ShowDeleteWaring { get; set; } = new IUIDelegateOperation<List<CoursewareListItem>, MessageResult>(); 5 6 /// <summary> 7 /// 删除动画 8 /// </summary> 9 public IUIDelegateOperation<List<CoursewareListItem>> DeleteCoursewaresAnimation { get; set; } = new IUIDelegateOperation<List<CoursewareListItem>>();
删除逻辑:
1 /// <summary> 2 /// 删除 3 /// </summary> 4 /// <param name="item"></param> 5 /// <returns></returns> 6 private async void DeleteCourseware_OnExecute(CoursewareListItem item) 7 { 8 await DeleteCoursewares(new List<CoursewareListItem>() { item }); 9 } 10 private async Task DeleteCoursewares(List<CoursewareListItem> items) 11 { 12 if (items.Count == 0) 13 { 14 return; 15 } 16 17 //弹出删除确认窗口 18 var messageResult = await ShowDeleteWaringShow.ExecuteWithResultAsync(items); 19 if (messageResult == MessageResult.Positive) 20 { 21 //删除服务器数据 22 Response deleteResponse = await WebService.DeleteItemAsync(items); 23 24 //删除失败 25 if (!deleteResponse.Success) 26 { 27 Notification.ShowInfo(deleteResponse.Message); 28 return; 29 } 30 //删除动画 31 await DeleteCoursewaresAnimation.ExecuteAsync(items); 32 33 //界面删除子项 34 items.ForEach(item => ItemsSource.Remove(item)); 35 36 //退出编辑模式 37 if (DocListState == EditStatus.Editing) 38 { 39 DocListState = EditStatus.Normal; 40 } 41 } 42 }
3. Xaml.cs后台
- 添加依赖属性后,通过属性变更触发,来完成弹出提示框/删除动画等交互。
- 执行交互时,需要同步等待时,应将动画执行等转化为同步逻辑。
添加依赖属性 - 删除窗口
属性变更触发方法,应该是一个异步方法,里面的逻辑应该为同步执行。这样ViewModel中才能同步等待交互的完成,并执行之后的逻辑。
1 /// <summary> 2 /// 删除窗口 3 /// </summary> 4 public static readonly DependencyProperty ShowDeleteWaringShowProperty = DependencyProperty.Register( 5 "ShowDeleteWaringShow", typeof(UIDelegateProgress<List<CoursewareListItem>, MessageResult>), typeof(CloudListView), new PropertyMetadata(default(UIDelegateProgress<List<CoursewareListItem>, MessageResult>), 6 (d, e) => ((UIDelegateProgress<List<CoursewareListItem>, MessageResult>)e.NewValue)?.StartAsync(((CloudListView)d).ShowDeleteWaringShow))); 7 8 private async Task<MessageResult> ShowDeleteWaringShow(List<CoursewareListItem> items) 9 { 10 var cmd = await DeleteWaringShow(items); 11 return cmd.Result; 12 } 13 14 public static void SetShowDeleteWaringShow(DependencyObject element, UIDelegateProgress<List<CoursewareListItem>, MessageResult> value) 15 { 16 element.SetValue(ShowDeleteWaringShowProperty, value); 17 } 18 19 public static UIDelegateProgress<List<CoursewareListItem>, MessageResult> GetShowDeleteWaringShow(DependencyObject element) 20 { 21 return (UIDelegateProgress<List<CoursewareListItem>, MessageResult>)element.GetValue(ShowDeleteWaringShowProperty); 22 }
添加依赖属性 - 删除动画
1 public static readonly DependencyProperty DeleteCoursewaresAnimationProperty = DependencyProperty.Register( 2 "DeleteCoursewaresAnimation", typeof(UIDelegateProgress<List<CoursewareListItem>>), typeof(CloudListView), new PropertyMetadata(default(UIDelegateProgress<List<CoursewareListItem>>), 3 (d, e) => ((UIDelegateProgress<List<CoursewareListItem>>)e.NewValue)?.StartAsync(((CloudListView)d).ExecuteDeleteCoursewaresAnimation))); 4 5 private async Task ExecuteDeleteCoursewaresAnimation(List<CoursewareListItem> coursewares) 6 { 7 List<Storyboard> storyboards = new List<Storyboard>(); 8 foreach (var courseware in coursewares) 9 { 10 var listBoxItem = DocumentsControl.ItemContainerGenerator.ContainerFromItem(courseware) as ListBoxItem; 11 var border = listBoxItem?.VisualDescendant<Border>(); 12 var storyboard = (Storyboard)border?.Resources["ItemRemovedStoryboard"]; 13 if (storyboard == null) 14 { 15 //如果找不到storyBoard,则中断动画的执行。因为删除多个Item,只执行一半的动画,界面会闪现俩次。 16 return; 17 } 18 storyboards.Add(storyboard); 19 } 20 //删除界面课件 21 await AsynchronousTransferHelper.ExecuteStoryboradAsync(storyboards); 22 } 23 24 public static void SetDeleteCoursewaresAnimation(DependencyObject element, UIDelegateProgress<List<CoursewareListItem>> value) 25 { 26 element.SetValue(DeleteCoursewaresAnimationProperty, value); 27 } 28 29 public static UIDelegateProgress<List<CoursewareListItem>> GetDeleteCoursewaresAnimation(DependencyObject element) 30 { 31 return (UIDelegateProgress<List<CoursewareListItem>>)element.GetValue(DeleteCoursewaresAnimationProperty); 32 }
动画的执行,怎么转为有同步等待呢?动画完成只有通过触发事件Completed才能确定。
如何将动画转化为同步,可参考之前写的博客:
1 /// <summary> 2 /// 执行动画 3 /// </summary> 4 /// <param name="storyboard"></param> 5 /// <returns></returns> 6 public static async Task ExecuteStoryboradAsync([NotNull] Storyboard storyboard) 7 { 8 if (storyboard == null) throw new ArgumentNullException(nameof(storyboard)); 9 10 AutoResetEvent autoResetEvent = new AutoResetEvent(false); 11 12 storyboard.Completed += OnStoryboardCompleted; 13 storyboard.Begin(); 14 15 void OnStoryboardCompleted(object sender, EventArgs e) 16 { 17 storyboard.Completed -= OnStoryboardCompleted; 18 autoResetEvent.Set(); 19 } 20 21 await Task.Run(() => { autoResetEvent.WaitOne(); }); 22 }
4. 交互处理辅助类 UIDelegateOperation
在UIDelegateOperation内部,每次调用时,都会新建一个UIDelegateProgress(委托进度)。委托进度,是界面交互的处理~
UIDelegateOperation:
1 /// <summary> 2 /// UI交互处理-提供可调用UI交互的操作 3 /// </summary> 4 public class UIDelegateOperation : BindableObject, IUIDelegateAction 5 { 6 private UIDelegateProgress _delegateProgress; 7 8 public UIDelegateProgress DelegateProgress 9 { 10 get => _delegateProgress; 11 private set 12 { 13 _delegateProgress = value; 14 OnPropertyChanged(); 15 } 16 } 17 18 /// <summary> 19 /// 执行 20 /// </summary> 21 public void Execute() 22 { 23 var delegateProgress = new UIDelegateProgress(); 24 delegateProgress.ProgressCompleted += () => 25 { 26 _delegateProgress = null; 27 }; 28 DelegateProgress = delegateProgress; 29 } 30 31 /// <summary> 32 /// 异步执行 33 /// 交互处理完成并回调 34 /// </summary> 35 public async Task ExecuteAsync() 36 { 37 AutoResetEvent autoResetEvent = new AutoResetEvent(false); 38 39 var delegateProgress = new UIDelegateProgress(); 40 delegateProgress.ProgressCompleted += () => 41 { 42 _delegateProgress = null; 43 44 autoResetEvent.Set(); 45 }; 46 DelegateProgress = delegateProgress; 47 await Task.Run(() => { autoResetEvent.WaitOne(); }); 48 } 49 } 50 51 /// <summary> 52 /// UI交互处理-提供可同步调用UI交互的操作 53 /// </summary> 54 /// <typeparam name="T">输入/输出类型</typeparam> 55 public class UIDelegateAction<T> : BindableObject, IUIDelegateAction<T> 56 { 57 private UIDelegateProgress<T> _delegateProgress; 58 59 public UIDelegateProgress<T> DelegateProgress 60 { 61 get => _delegateProgress; 62 private set 63 { 64 _delegateProgress = value; 65 OnPropertyChanged(); 66 } 67 } 68 /// <summary> 69 /// 执行 70 /// </summary> 71 public void Execute(T parameter) 72 { 73 var delegateProgress = new UIDelegateProgress<T>(parameter); 74 delegateProgress.ProgressCompleted += () => 75 { 76 _delegateProgress = null; 77 }; 78 DelegateProgress = delegateProgress; 79 } 80 /// <summary> 81 /// 异步执行 82 /// 交互处理完成并回调 83 /// </summary> 84 public async Task ExecuteAsync(T parameter) 85 { 86 AutoResetEvent autoResetEvent = new AutoResetEvent(false); 87 88 var delegateProgress = new UIDelegateProgress<T>(parameter); 89 delegateProgress.ProgressCompleted += () => 90 { 91 _delegateProgress = null; 92 93 autoResetEvent.Set(); 94 }; 95 DelegateProgress = delegateProgress; 96 97 await Task.Run(() => { autoResetEvent.WaitOne(); }); 98 } 99 100 /// <summary> 101 /// 异步执行并返回结果 102 /// </summary> 103 public async Task<T> ExecuteWithResultAsync() 104 { 105 AutoResetEvent autoResetEvent = new AutoResetEvent(false); 106 107 var delegateProgress = new UIDelegateProgress<T>(); 108 delegateProgress.ProgressCompleted += () => 109 { 110 _delegateProgress = null; 111 112 autoResetEvent.Set(); 113 }; 114 DelegateProgress = delegateProgress; 115 116 await Task.Run(() => { autoResetEvent.WaitOne(); }); 117 118 return delegateProgress.Result; 119 } 120 } 121 122 /// <summary> 123 /// UI交互处理-提供可同步调用UI交互的操作 124 /// </summary> 125 /// <typeparam name="TInput">输入类型</typeparam> 126 /// <typeparam name="TOut">输出类型</typeparam> 127 public class UIDelegateAction<TInput, TOut> : BindableObject, IUIDelegateAction<TInput, TOut> 128 { 129 private UIDelegateProgress<TInput, TOut> _delegateProgress; 130 131 public UIDelegateProgress<TInput, TOut> DelegateProgress 132 { 133 get => _delegateProgress; 134 private set 135 { 136 _delegateProgress = value; 137 OnPropertyChanged(); 138 } 139 } 140 /// <summary> 141 /// 执行 142 /// </summary> 143 public void Execute(TInput parameter) 144 { 145 var delegateProgress = new UIDelegateProgress<TInput, TOut>(parameter); 146 delegateProgress.ProgressCompleted += () => 147 { 148 _delegateProgress = null; 149 }; 150 DelegateProgress = delegateProgress; 151 } 152 153 /// <summary> 154 /// 执行并返回结果 155 /// </summary> 156 public TOut ExecuteWithResult(TInput parameter) 157 { 158 var delegateProgress = new UIDelegateProgress<TInput, TOut>(parameter); 159 delegateProgress.ProgressCompleted += () => 160 { 161 _delegateProgress = null; 162 }; 163 DelegateProgress = delegateProgress; 164 return delegateProgress.Result; 165 } 166 167 /// <summary> 168 /// 异步执行并返回结果 169 /// </summary> 170 public async Task<TOut> ExecuteWithResultAsync(TInput parameter) 171 { 172 var delegateProgress = new UIDelegateProgress<TInput, TOut>(parameter); 173 await SetDelegateProgress(delegateProgress); 174 return delegateProgress.Result; 175 } 176 private async Task SetDelegateProgress(UIDelegateProgress<TInput, TOut> delegateProgress) 177 { 178 AutoResetEvent autoResetEvent = new AutoResetEvent(false); 179 180 delegateProgress.ProgressCompleted += () => 181 { 182 _delegateProgress = null; 183 autoResetEvent.Set(); 184 }; 185 DelegateProgress = delegateProgress; 186 await Task.Run(() => { autoResetEvent.WaitOne(); }); 187 } 188 } 189 190 /// <summary> 191 /// UI交互处理接口 192 /// </summary> 193 public interface IUIDelegateAction 194 { 195 196 UIDelegateProgress DelegateProgress { get; } 197 198 /// <summary> 199 /// 执行 200 /// </summary> 201 void Execute(); 202 203 /// <summary> 204 /// 异步执行 205 /// </summary> 206 Task ExecuteAsync(); 207 } 208 209 /// <summary> 210 /// UI交互处理接口 211 /// </summary> 212 /// <typeparam name="T">输入/输出类型</typeparam> 213 public interface IUIDelegateAction<T> 214 { 215 UIDelegateProgress<T> DelegateProgress { get; } 216 217 /// <summary> 218 /// 执行 219 /// </summary> 220 void Execute(T parameter); 221 222 /// <summary> 223 /// 异步执行 224 /// </summary> 225 Task ExecuteAsync(T parameter); 226 227 /// <summary> 228 /// 异步执行并返回结果 229 /// </summary> 230 Task<T> ExecuteWithResultAsync(); 231 } 232 233 /// <summary> 234 /// UI交互处理接口 235 /// </summary> 236 /// <typeparam name="TInput">输入类型</typeparam> 237 /// <typeparam name="TOut">输出类型</typeparam> 238 public interface IUIDelegateAction<TInput, TOut> 239 { 240 UIDelegateProgress<TInput, TOut> DelegateProgress { get; } 241 242 /// <summary> 243 /// 执行 244 /// </summary> 245 void Execute(TInput parameter); 246 247 /// <summary> 248 /// 执行并返回结果 249 /// </summary> 250 TOut ExecuteWithResult(TInput parameter); 251 252 /// <summary> 253 /// 异步执行并返回结果 254 /// </summary> 255 Task<TOut> ExecuteWithResultAsync(TInput parameter); 256 }
UIDelegateProgress:
1 /// <summary> 2 /// 委托进度 3 /// </summary> 4 public class UIDelegateProgress 5 { 6 public event Action ProgressCompleted; 7 8 /// <summary> 9 /// UI委托处理 10 /// </summary> 11 /// <param name="uiTask"></param> 12 public async void StartAsync(Func<Task> uiTask) 13 { 14 try 15 { 16 await uiTask.Invoke(); 17 } 18 catch (InvalidOperationException e) 19 { 20 Log.Error("UI交互处理,产生异常!", e); 21 } 22 finally 23 { 24 ProgressCompleted?.Invoke(); 25 } 26 } 27 28 /// <summary> 29 /// UI委托处理 30 /// </summary> 31 /// <param name="uiTask"></param> 32 public void Start(Action uiTask) 33 { 34 try 35 { 36 uiTask.Invoke(); 37 } 38 catch (InvalidOperationException e) 39 { 40 Log.Error("UI交互处理,产生异常!", e); 41 } 42 finally 43 { 44 ProgressCompleted?.Invoke(); 45 } 46 } 47 } 48 49 /// <summary> 50 /// 委托进度 51 /// </summary> 52 public class UIDelegateProgress<T> 53 { 54 public event Action ProgressCompleted; 55 56 /// <summary> 57 /// 输入参数 58 /// </summary> 59 public T Parameter { get; set; } 60 61 /// <summary> 62 /// 输出参数 63 /// </summary> 64 public T Result { get; set; } 65 66 public UIDelegateProgress() 67 { 68 69 } 70 public UIDelegateProgress(T parameter) 71 { 72 Parameter = parameter; 73 } 74 75 /// <summary> 76 /// UI委托处理 77 /// </summary> 78 /// <param name="uiTask"></param> 79 public void Start(Action<T> uiTask) 80 { 81 try 82 { 83 uiTask.Invoke(Parameter); 84 } 85 catch (InvalidOperationException e) 86 { 87 Log.Error("UI交互处理,产生异常!", e); 88 } 89 finally 90 { 91 ProgressCompleted?.Invoke(); 92 } 93 } 94 95 /// <summary> 96 /// UI委托处理 97 /// </summary> 98 /// <param name="uiTask"></param> 99 public async void StartAsync(Func<T, Task> uiTask) 100 { 101 try 102 { 103 await uiTask.Invoke(Parameter); 104 } 105 catch (InvalidOperationException e) 106 { 107 Log.Error("UI交互处理,产生异常!", e); 108 } 109 finally 110 { 111 ProgressCompleted?.Invoke(); 112 } 113 } 114 115 /// <summary> 116 /// UI委托处理 117 /// </summary> 118 /// <param name="uiTask"></param> 119 public void Start(Func<T> uiTask) 120 { 121 try 122 { 123 Result = uiTask.Invoke(); 124 } 125 catch (InvalidOperationException e) 126 { 127 Log.Error("UI交互处理,产生异常!", e); 128 } 129 finally 130 { 131 ProgressCompleted?.Invoke(); 132 } 133 } 134 135 /// <summary> 136 /// UI委托处理 137 /// </summary> 138 /// <param name="uiTask"></param> 139 public async void StartAsync(Func<Task<T>> uiTask) 140 { 141 try 142 { 143 Result = await uiTask.Invoke(); 144 } 145 catch (InvalidOperationException e) 146 { 147 Log.Error("UI交互处理,产生异常!", e); 148 } 149 finally 150 { 151 ProgressCompleted?.Invoke(); 152 } 153 } 154 } 155 156 /// <summary> 157 /// 委托进度 158 /// </summary> 159 public class UIDelegateProgress<TInput, TOut> 160 { 161 public event Action ProgressCompleted; 162 163 /// <summary> 164 /// 输入参数 165 /// </summary> 166 public TInput Parameter { get; set; } 167 168 /// <summary> 169 /// 输出参数 170 /// </summary> 171 public TOut Result { get; set; } 172 173 public UIDelegateProgress(TInput parameter) 174 { 175 Parameter = parameter; 176 } 177 178 /// <summary> 179 /// UI委托处理 180 /// </summary> 181 /// <param name="uiTask"></param> 182 public async void StartAsync(Func<TInput, Task<TOut>> uiTask) 183 { 184 try 185 { 186 Result = await uiTask.Invoke(Parameter); 187 } 188 catch (InvalidOperationException e) 189 { 190 Log.Error("UI交互处理,产生异常!", e); 191 } 192 finally 193 { 194 ProgressCompleted?.Invoke(); 195 } 196 } 197 198 /// <summary> 199 /// UI委托处理 200 /// </summary> 201 /// <param name="uiTask"></param> 202 public void Start(Func<TOut> uiTask) 203 { 204 try 205 { 206 uiTask.Invoke(); 207 } 208 catch (InvalidOperationException e) 209 { 210 Log.Error("UI交互处理,产生异常!", e); 211 } 212 finally 213 { 214 ProgressCompleted?.Invoke(); 215 } 216 } 217 218 /// <summary> 219 /// UI委托处理 220 /// </summary> 221 /// <param name="uiTask"></param> 222 public void Start(Func<TInput, TOut> uiTask) 223 { 224 try 225 { 226 Result = uiTask.Invoke(Parameter); 227 } 228 catch (InvalidOperationException e) 229 { 230 Log.Error("UI交互处理,产生异常!", e); 231 } 232 finally 233 { 234 ProgressCompleted?.Invoke(); 235 } 236 } 237 }
Demo中,举例了界面的删除操作
InvokeCommandAction
通过InvokeCommandAction 的使用,WPF任意事件都可以绑定Command,将业务逻辑放在ViewModel中。如:
1 <TextBlock> 2 <i:Interaction.Triggers> 3 <i:EventTrigger EventName="MouseLeftButtonDown"> 4 <i:InvokeCommandAction Command="{Binding MouseLeftButtonDownCommand}"/> 5 </i:EventTrigger> 6 </i:Interaction.Triggers> 7 </TextBlock>
关键字:UI分离,交互与数据分离,动画同步,单元测试
上一篇: 宿管阿姨发现可爱滴二货同学
下一篇: 爆囧在胡同巷子里