WP7有约(一):课程安排2
2012-01-13 15:16:03 作者:sandy 来源: 浏览次数:0
代码 15
这样,构造函数和Rollback方法只需调用这个辅助方法就行了。值得提醒的是,在应用程序首次启动时,课程表的数据文件还不存在,此时,我们只需为Courses属性创建一个空的集合就行了。当用户执行提交操作时,会调用Commit方法,把Courses属性序列化到独立存储区:

代码 16
好了,数据存储的开发也完成了,但是,我们怎么把它和前面设计的用户界面关联起来呢?这正是接下来要讲的。
把前端和后端连接起来
还记得Expression Blend是怎么做的吗?它为MainPage页创建一个与之对应的MainViewModel类,并在代码隐藏文件里把后者的实例绑到前者的DataContext属性上,而剩下的事情就交给数据绑定来处理。接下来,我们将会模范这种做法,把前端和后端连接起来。
我们知道,一个课程表包含若干列,每列都包含了一个标题和一组当天的课程,整个课程表对应于CourseTimetablePage页,里面的每列对应于一个Pivot项,其中,列的标题将会作为Pivot项的标题显示,而列所包含的那组当天的课程将会在Pivot项所包含的ListBox里显示。为了方便理解,我们把它们之间的映射关系制成下表:
页面
页面的抽象模型
CourseTimetablePage页
CourseTimetableViewModel类
Pivot项
CourseTimetableColumnViewModel类
Header属性
Header属性
ListBox控件
Courses属性
表 3
我们先来看看CourseTimetableColumnViewModel类。在ViewModels文件夹里创建一个CourseTimetableColumnViewModel类,并让它实现INotifyPropertyChanged接口,然后为它添加一个Header属性:

代码 17
这个属性将会在构造函数里初始化:

代码 18
这些都不难,稍微有点麻烦的是Courses属性,我们该如何实现这个属性呢?目前,JsonCourseStore类的Courses属性返回的是全部课程,而CourseTimetableColumnViewModel类的Courses属性仅返回当天的课程,显然,我们要做过滤,此外,由于JsonCourseStore类返回的课程没有固定顺序,我们还要排序。一个可能的方案是通过LINQ从源集合筛选当天课程,并根据时间进行排序,然后添加到目标集合,我们还需要监听源集合的相关事件,以便在源集合的内容发生更改时把更改反映到目标集合。感觉上这个方案需要不少代码,还有没有别的方案呢?有,我们可以使用CollectionViewSource类,它能根据我们指定的过滤和排序条件提供集合视图,我们可以通过它的View属性访问这个视图:

代码 19
_courses的初始化也是在构造函数里进行:

代码 20
现在,请思考一下,我们是不是直接给Source属性创建一个JsonCourseStore对象呢?回忆一下JsonCourseStore类的设计思路,CRUD四个操作都是直接在它的Courses属性上进行的,而C和U这两个操作是发生在另一个页面的(NewOrEditCoursePage页),这意味着我们需要一个全局的JsonCourseStore对象。解决方案有很多,你可以把JsonCourseStore类设计成单例模式,也可以使用依赖注入容器,而最简单的做法莫过于在App类里添加一个静态属性了:

代码 21
这样,Source属性的初始化就可以通过下面这句来完成了:

代码 22
诚然,这并不是什么好的做法,你可能会坚持使用依赖注入容器,因为这样能更好的降低对象之间的耦合度,如果你已经知道怎么做,请不要犹豫,立即行动,鉴于这篇文章的内容已经很多了,所以我想把这个内容留到后面的文章。
接着来看CourseTimetableViewModel类,在ViewModels文件夹里创建一个CourseTimetableViewModel类,并让它实现INotifyPropertyChanged接口。毫无疑问,它应该包含一组CourseTimetableColumnViewModel对象,我们可以创建一个Columns属性来存放它们:

代码 23
此外,我们还需要一个属性来跟踪和当前显示的Pivot项绑定的是哪个CourseTimetableColumnViewModel对象:

代码 24
这个属性将会和Pivot控件的SelectedIndex属性进行双向绑定。这两个属性都会在构造函数里初始化:

代码 25
我们知道,用户体验很重要,我们应该尽可能减少用户获取所需信息的步骤,当用户打开课程表时最想看到的应该是今天的课程,这正是为什么我要把SelectedColumnIndex属性的值初始化为DateTime.Today.DayOfWeek属性的值。然而,当一种观点产生的时候,反面观点也会随之而来,有人可能建议每次打开课程表都能看到相同的东西,比如说,显示一周第一天的课程,也有人建议和上次离开该页面时的东西保持一致。事实上,这些观点没有谁对谁错,它们的目的都是为了改善用户体验,你可以按照你认为正确的去做,或许,我们也可以考虑在后续的版本里通过选项设置让用户选择。
现在,我们可以着手处理绑定了。打开CourseTimetablePage.xaml,切换到XAML模式,把现有的两个Pivot项删掉或者注释掉,因为我们不再单独处理个别Pivot项,而是把CourseTimetableViewModel类的Columns属性绑到Pivot控件的ItemsSource属性上,由Pivot控件动态创建Pivot项。此外,我们还要把CourseTimetableViewModel类的SelectedColumnIndex属性绑到Pivot控件的SelectedIndex属性上,并设置为双向绑定。设置好绑定后,Pivot控件的XAML应该是这样的:

代码 26
但是,Pivot控件又从何得知应该创建怎样的Pivot项呢?事实上,它无从知晓,也不知道该如何使用CourseTimetableColumnViewModel 类Columns属性,所以我们必须通过某种方式告诉它我们希望它怎么做,而这种方式就是数据模板。我们需要创建两个数据模板,一个用于Pivot项的标题,另一个用于Pivot项的内容,这两个数据模板将会放在页面的资源字典里。第一个数据模板很简单,只需创建一个TextBlock,并把Header属性绑到它的Text属性上:

代码 27
另一个数据模板也不难,你可以把其中一个Pivot项的XAML代码复制过来,并修改ItemsSource属性的绑定:

代码 28
之前Expression Blend为我们生成的Course类的属性采用全小写命名方式,而现在已经改为Pascal命名方式,所以courseCollectionItemTemplate数据模板的相关绑定也要改过来:

代码 29
数据模板创建好之后就可以应用到Pivot控件了:

代码 30
由于我们不再使用Expression Blend生成的示例数据了,所以你可以把包含Pivot控件的Grid的DataContext属性去掉,然后在代码隐藏文件的构造函数里设置DataContext属性:

代码 31
好了,我知道你很心急了,按F5吧,然后单击中间的按钮……噢!出错了!

图 43
怎么回事呢?经过一番调查,我发现问题出在代码25的设置SelectedColumnIndex属性那行,如果你去掉这行,你会发现什么问题也没有,如果你尝试为它硬编码一个值,你会发现同一个值有时候会抛异常,有时候不会,非常不稳定,为什么呢?原来,当我们设置SelectedColumnIndex属性的时候,它会把我们给它设的值同步到Pivot控件的SelectedIndex属性,但此时Pivot控件有可能还没完全构建好,于是就出错了。解决方法是把代码25出错的那行删掉,在代码31设置DataContext属性的那行后面加上这句:

图 44
值得提醒的是,通过Loaded事件来设置SelectedColumnIndex属性是必须的,因为此时页面及其包含的控件已经构建好了,如果你只是把设置SelectedColumnIndex属性的代码从代码25复制粘贴到代码31,那么问题依旧存在。现在,当你打开课程表时,你会看到Pivot控件快速滑到今天的课程:

图 45
哎哟,课程表空荡荡的,怎么检查课程的显示格式是否正确?添加几个看看吧。但是,添加课程的功能还没有……
操作课程表
我们知道,新增和修改这两个操作是共用同一个页面的,但它们的内部逻辑又稍微有点不同, 你可以分别为它们创建两个不同的ViewModel类,针对不同的操作为页面创建不同的ViewModel对象,也可以在同一个ViewModel类里通过标记变量和条件语句区分两种不同的逻辑,你还可以像我这样,在ViewModels文件夹里创建一个NewOrEditCourseViewModel抽象类,让它实现INotifyPropertyChanged接口,并创建Title和Course两个属性以及Submit和Discard两个抽象方法:

代码 32
接着,在NewOrEditCourseViewModel类里创建NewCourseViewModel和EditCourseViewModel两个私有类,并让它们继承NewOrEditCourseViewModel类。下面,我们先看NewCourseViewModel类。
课程表上的每个课程都会关联到一周的某天,但NewOrEditCoursePage页上却没有地方设置星期几(参见图32),这是为什么呢?Pivot控件的一个特点是每次只能显示一个Pivot项,这意味着整个课程表每次只能显示一天的课程,如果把这天看做上下文,当用户单击Application Bar上的新增按钮时,应用程序就可以从上下文获知应该把课程添加到哪一天,从而为用户省下设置星期几这个步骤。我们可以通过参数传递这个数据,然后在构造函数里使用它创建Course对象:

代码 33
当用户单击确定时,就会把课程添加到JsonCourseStore,而单击取消的话就什么都不做:

代码 34
那么,EditCourseViewModel类呢?当用户单击Application Bar上的编辑按钮时,它需要的不是今天星期几,而是用户当前选中的课程是什么,我们又该如何告诉它呢?我们知道,同一天的课程在时间上是互斥的,因为同一时间你不可能在不同教室上课(除非你懂影分身术),换句话说,Course类的Day和StartTime这两个属性组合起来可以成为唯一标识。通过这个唯一标识,我们可以从JsonCourseStore里获取用户当前选中的课程,但是,我们是否把获取到的课程直接赋给EditCourseViewModel的Course属性呢?想想看,Course属性将会和NewOrEditCoursePage页上的控件进行双向绑定,当用户编辑控件的内容时,数据会直接反映在Course属性上,如果Course属性就是从JsonCourseStore里获取到的课程,那么数据就会直接提交到JsonCourseStore。如果你选择这样做,你得先做个备份,假如用户单击取消,你就可以把备份的数据还原回去。另一个做法是从JsonCourseStore里获取用户当前选中的课程,然后克隆一份赋给Course属性,当用户单击确定时,就把Course属性的数据更新过去,而单击取消的话也是什么都不做。这里选择后面那种做法:

代码 35
创建好NewCourseViewModel和EditCourseViewModel两个私有类之后,我们需要考虑一下如何访问它们的实例,办法可能有很多,其中最简单的是在NewOrEditCourseViewModel类里创建两个静态方法,分别用于创建这两个类的实例:

代码 36
有了实例之后,我们就要考虑数据绑定的问题了,这个不难处理,我们总共也只有五个绑定需要创建,你可以参照下表修改NewOrEditCoursePage.xaml的内容:
描述
类型
属性
绑定表达式
页面标题
TextBlock
Text
{Binding Title}
课程名称
TextBox
Text
{Binding Course.Name, Mode=TwoWay}
上课时间
TimePicker
Value
{Binding Course.StartTime, Mode=TwoWay}
下课时间
TimePicker
Value
{Binding Course.EndTime, Mode=TwoWay}
上课地点
TextBox
Text
{Binding Course.Location, Mode=TwoWay}
表 4
由于页面之间的切换是通过Navigate方法来实现的(参见代码2),而它又只接受Uri对象作为参数,于是查询字符串就自然而然地成为页面之间传递数据的主要途径了。我们需要的参数有三个:action、day和id,其中action的值有new和edit两种,分别对应新建和编辑两种操作,action可以和day或id搭配使用,但不会同时使用三个参数。我们可以根据这些参数的值创建对应的ViewModel对象,然后把它赋给页面的DataContext属性:

代码 37
现在的问题是,这段代码应该放在哪里?构造函数?还是别的什么地方?这取决于NavigationCacheMode属性的值是什么,如果它的值是Disabled,这意味着页面不会缓存,每次都会创建新的实例,那么我们可以把这段代码放在页面的构造函数里,否则,我们应该把它放在OnNavigatedTo方法里,事实上,任何时候我们来到一个页面,该页面的OnNavigatedTo方法都会被调用,所以放在这里比较保险。你可能会问,为什么CourseTimetablePage页的DataContext属性是在构造函数里设置的?好问题!那是因为CourseTimetablePage页的ViewModel对象由始至终都未曾改变,而NewOrEditCoursePage页的ViewModel对象及与之绑定的Course对象每次都可能不同,页面的缓存可能会导致用户看到"过期"的信息。
当用户单击确定时,将会调用ViewModel对象的Submit方法,然后调用NavigationService对象的GoBack方法返回课程表;而单击取消的话就直接返回:

代码 38
现在,让我们回到CourseTimetablePage.cs,把新建按钮的Click事件处理程序改成下面这样:

代码 39
需要说明的是,Pivot项的标题是"星期X",对应于Course对象的Day属性,因此我们可以把它作为参数通过查询字符串传给NewOrEditCoursePage页。接着,为编辑按钮创建一个Click事件处理程序:

代码 40
最后是删除按钮的Click事件处理程序:

代码 41
你可能会问,为什么执行删除之前不给用户提示一下?嗯,这是个值得考虑的问题,有时候你不提示用户会质问你万一删错了怎么办,有时候你提示了用户也会抱怨这样做很烦,一般而言,仅仅在执行操作之前给用户提示一下是远远不够的,你还需要让用户可以设置以后不再提示,以及告诉用户随时可以在哪里开启提示。不过,你可以尝试实现一个简单的提示,至于可以设置的提示将会在以后的文章里探讨。
现在还缺什么吗?噢,对了,还缺两个菜单项,打开CourseTimetablePage.xaml,切换到XAML模式,在<shell:ApplicationBar></shell:ApplicationBar>里添加两个Application Bar菜单项:

代码 42
它们的Click事件处理程序比较简单,只是分别调用JsonCourseStore的Commit方法和Rollback方法:

代码 43
我已经等不及要按F5了,单击主页中间的按钮打开课程表,单击新建按钮,输入课程名称:

图 46
修改上课时间:

图 47
单击确定返回:

图 48
奇怪了!我刚才明明输入了课程名称,为什么现在没了呢?而且上课时间也没改过来!究竟发生了什么事?
我查看了Silverlight for Windows Phone Toolkit的代码,在DateTimePickerPageBase类的HandleClosedStoryboardCompleted方法里可以看到,当我们设好时间并单击确定时,它会调用NavigationService对象的GoBack方法返回。前面我们提到,任何时候当我们来到一个页面时,该页面的OnNavigatedTo方法就会被调用,现在,我们在NewOrEditCoursePage.cs的OnNavigatedTo方法里设置一个断点,然后从设置时间的页面返回,看看会发生什么事:

图 49
从OnNavigatedTo方法的e参数和NavigationContext对象的QueryString属性不难看出,GoBack方法并没改变导航URI,即查询字符串的action和day两个参数都完好如初,于是第一个条件语句被执行,创建一个全新的ViewModel对象并把它赋给页面的DataContext属性,就像刚刚打开这个页面似的,但是,此时页面的DataContext属性已经有ViewModel对象了呀!这正是问题所在,既然知道了原因,问题就不难解决了,我们只需在执行这块代码之前先判断一下DataContext属性是否为空就行了。
现在按F5重新运行应用程序,并新建一个课程,完了之后你就会在课程表里看到它了:

图 50
慢着!时间的显示格式有问题,我期望它显示为8:10 – 10:00而不是现在这样,怎么办?这个时候就轮到转换器出场了。
首先,创建一个Utils文件夹,在里面添加一个TimeConverter类,并让它实现IValueConverter接口,实现这个接口只需实现两个方法,一个是Convert方法,用于把Course对象的StartTime属性和EndTime属性的值转换为显示在UI上的字符串,另一个是ConvertBack方法,这个方法只在双向绑定时才会派上用场,而这里是单向绑定,所以我们不必为它提供实现:

代码 44
接着在CourseTimetablePage.xaml的资源字典里添加一个TimeConverter对象:

代码 45
然后把那两个TextBlock的Text属性的绑定表达式分别改为"{Binding StartTime, Converter={StaticResource timeConverter}}"和"{Binding EndTime, Converter={StaticResource timeConverter}}"。
现在按F5重新运行应用程序,并新建一个课程,这次时间的显示格式就没问题了:

图 51
选中这个课程,并单击编辑按钮:

图 52
嗯,很好,页面标题和课程信息都正确显示了,修改一下并按确定返回:

图 53
课程信息的更改也正确反映到课程表了。现在,确保课程处于选中状态,单击删除按钮,噢,出错了:

图 54
这个问题好解决,我们只需在使用e.Item之前判断一下它是否为null就行了:

代码 46
现在按F5重新运行应用程序,并新建一个课程,先别删除这个课程,我们需要用它来执行以下测试:
这样,构造函数和Rollback方法只需调用这个辅助方法就行了。值得提醒的是,在应用程序首次启动时,课程表的数据文件还不存在,此时,我们只需为Courses属性创建一个空的集合就行了。当用户执行提交操作时,会调用Commit方法,把Courses属性序列化到独立存储区:

代码 16
好了,数据存储的开发也完成了,但是,我们怎么把它和前面设计的用户界面关联起来呢?这正是接下来要讲的。
把前端和后端连接起来
还记得Expression Blend是怎么做的吗?它为MainPage页创建一个与之对应的MainViewModel类,并在代码隐藏文件里把后者的实例绑到前者的DataContext属性上,而剩下的事情就交给数据绑定来处理。接下来,我们将会模范这种做法,把前端和后端连接起来。
我们知道,一个课程表包含若干列,每列都包含了一个标题和一组当天的课程,整个课程表对应于CourseTimetablePage页,里面的每列对应于一个Pivot项,其中,列的标题将会作为Pivot项的标题显示,而列所包含的那组当天的课程将会在Pivot项所包含的ListBox里显示。为了方便理解,我们把它们之间的映射关系制成下表:
页面的抽象模型
CourseTimetableViewModel类
CourseTimetableColumnViewModel类
Header属性
Courses属性
表 3
我们先来看看CourseTimetableColumnViewModel类。在ViewModels文件夹里创建一个CourseTimetableColumnViewModel类,并让它实现INotifyPropertyChanged接口,然后为它添加一个Header属性:

代码 17
这个属性将会在构造函数里初始化:

代码 18
这些都不难,稍微有点麻烦的是Courses属性,我们该如何实现这个属性呢?目前,JsonCourseStore类的Courses属性返回的是全部课程,而CourseTimetableColumnViewModel类的Courses属性仅返回当天的课程,显然,我们要做过滤,此外,由于JsonCourseStore类返回的课程没有固定顺序,我们还要排序。一个可能的方案是通过LINQ从源集合筛选当天课程,并根据时间进行排序,然后添加到目标集合,我们还需要监听源集合的相关事件,以便在源集合的内容发生更改时把更改反映到目标集合。感觉上这个方案需要不少代码,还有没有别的方案呢?有,我们可以使用CollectionViewSource类,它能根据我们指定的过滤和排序条件提供集合视图,我们可以通过它的View属性访问这个视图:

代码 19
_courses的初始化也是在构造函数里进行:

代码 20
现在,请思考一下,我们是不是直接给Source属性创建一个JsonCourseStore对象呢?回忆一下JsonCourseStore类的设计思路,CRUD四个操作都是直接在它的Courses属性上进行的,而C和U这两个操作是发生在另一个页面的(NewOrEditCoursePage页),这意味着我们需要一个全局的JsonCourseStore对象。解决方案有很多,你可以把JsonCourseStore类设计成单例模式,也可以使用依赖注入容器,而最简单的做法莫过于在App类里添加一个静态属性了:

代码 21
这样,Source属性的初始化就可以通过下面这句来完成了:

代码 22
诚然,这并不是什么好的做法,你可能会坚持使用依赖注入容器,因为这样能更好的降低对象之间的耦合度,如果你已经知道怎么做,请不要犹豫,立即行动,鉴于这篇文章的内容已经很多了,所以我想把这个内容留到后面的文章。
接着来看CourseTimetableViewModel类,在ViewModels文件夹里创建一个CourseTimetableViewModel类,并让它实现INotifyPropertyChanged接口。毫无疑问,它应该包含一组CourseTimetableColumnViewModel对象,我们可以创建一个Columns属性来存放它们:

代码 23
此外,我们还需要一个属性来跟踪和当前显示的Pivot项绑定的是哪个CourseTimetableColumnViewModel对象:

代码 24
这个属性将会和Pivot控件的SelectedIndex属性进行双向绑定。这两个属性都会在构造函数里初始化:

代码 25
我们知道,用户体验很重要,我们应该尽可能减少用户获取所需信息的步骤,当用户打开课程表时最想看到的应该是今天的课程,这正是为什么我要把SelectedColumnIndex属性的值初始化为DateTime.Today.DayOfWeek属性的值。然而,当一种观点产生的时候,反面观点也会随之而来,有人可能建议每次打开课程表都能看到相同的东西,比如说,显示一周第一天的课程,也有人建议和上次离开该页面时的东西保持一致。事实上,这些观点没有谁对谁错,它们的目的都是为了改善用户体验,你可以按照你认为正确的去做,或许,我们也可以考虑在后续的版本里通过选项设置让用户选择。
现在,我们可以着手处理绑定了。打开CourseTimetablePage.xaml,切换到XAML模式,把现有的两个Pivot项删掉或者注释掉,因为我们不再单独处理个别Pivot项,而是把CourseTimetableViewModel类的Columns属性绑到Pivot控件的ItemsSource属性上,由Pivot控件动态创建Pivot项。此外,我们还要把CourseTimetableViewModel类的SelectedColumnIndex属性绑到Pivot控件的SelectedIndex属性上,并设置为双向绑定。设置好绑定后,Pivot控件的XAML应该是这样的:

代码 26
但是,Pivot控件又从何得知应该创建怎样的Pivot项呢?事实上,它无从知晓,也不知道该如何使用CourseTimetableColumnViewModel 类Columns属性,所以我们必须通过某种方式告诉它我们希望它怎么做,而这种方式就是数据模板。我们需要创建两个数据模板,一个用于Pivot项的标题,另一个用于Pivot项的内容,这两个数据模板将会放在页面的资源字典里。第一个数据模板很简单,只需创建一个TextBlock,并把Header属性绑到它的Text属性上:

代码 27
另一个数据模板也不难,你可以把其中一个Pivot项的XAML代码复制过来,并修改ItemsSource属性的绑定:

代码 28
之前Expression Blend为我们生成的Course类的属性采用全小写命名方式,而现在已经改为Pascal命名方式,所以courseCollectionItemTemplate数据模板的相关绑定也要改过来:

代码 29
数据模板创建好之后就可以应用到Pivot控件了:

代码 30
由于我们不再使用Expression Blend生成的示例数据了,所以你可以把包含Pivot控件的Grid的DataContext属性去掉,然后在代码隐藏文件的构造函数里设置DataContext属性:

代码 31
好了,我知道你很心急了,按F5吧,然后单击中间的按钮……噢!出错了!

图 43
怎么回事呢?经过一番调查,我发现问题出在代码25的设置SelectedColumnIndex属性那行,如果你去掉这行,你会发现什么问题也没有,如果你尝试为它硬编码一个值,你会发现同一个值有时候会抛异常,有时候不会,非常不稳定,为什么呢?原来,当我们设置SelectedColumnIndex属性的时候,它会把我们给它设的值同步到Pivot控件的SelectedIndex属性,但此时Pivot控件有可能还没完全构建好,于是就出错了。解决方法是把代码25出错的那行删掉,在代码31设置DataContext属性的那行后面加上这句:

图 44
值得提醒的是,通过Loaded事件来设置SelectedColumnIndex属性是必须的,因为此时页面及其包含的控件已经构建好了,如果你只是把设置SelectedColumnIndex属性的代码从代码25复制粘贴到代码31,那么问题依旧存在。现在,当你打开课程表时,你会看到Pivot控件快速滑到今天的课程:

图 45
哎哟,课程表空荡荡的,怎么检查课程的显示格式是否正确?添加几个看看吧。但是,添加课程的功能还没有……
操作课程表
我们知道,新增和修改这两个操作是共用同一个页面的,但它们的内部逻辑又稍微有点不同, 你可以分别为它们创建两个不同的ViewModel类,针对不同的操作为页面创建不同的ViewModel对象,也可以在同一个ViewModel类里通过标记变量和条件语句区分两种不同的逻辑,你还可以像我这样,在ViewModels文件夹里创建一个NewOrEditCourseViewModel抽象类,让它实现INotifyPropertyChanged接口,并创建Title和Course两个属性以及Submit和Discard两个抽象方法:

代码 32
接着,在NewOrEditCourseViewModel类里创建NewCourseViewModel和EditCourseViewModel两个私有类,并让它们继承NewOrEditCourseViewModel类。下面,我们先看NewCourseViewModel类。
课程表上的每个课程都会关联到一周的某天,但NewOrEditCoursePage页上却没有地方设置星期几(参见图32),这是为什么呢?Pivot控件的一个特点是每次只能显示一个Pivot项,这意味着整个课程表每次只能显示一天的课程,如果把这天看做上下文,当用户单击Application Bar上的新增按钮时,应用程序就可以从上下文获知应该把课程添加到哪一天,从而为用户省下设置星期几这个步骤。我们可以通过参数传递这个数据,然后在构造函数里使用它创建Course对象:

代码 33
当用户单击确定时,就会把课程添加到JsonCourseStore,而单击取消的话就什么都不做:

代码 34
那么,EditCourseViewModel类呢?当用户单击Application Bar上的编辑按钮时,它需要的不是今天星期几,而是用户当前选中的课程是什么,我们又该如何告诉它呢?我们知道,同一天的课程在时间上是互斥的,因为同一时间你不可能在不同教室上课(除非你懂影分身术),换句话说,Course类的Day和StartTime这两个属性组合起来可以成为唯一标识。通过这个唯一标识,我们可以从JsonCourseStore里获取用户当前选中的课程,但是,我们是否把获取到的课程直接赋给EditCourseViewModel的Course属性呢?想想看,Course属性将会和NewOrEditCoursePage页上的控件进行双向绑定,当用户编辑控件的内容时,数据会直接反映在Course属性上,如果Course属性就是从JsonCourseStore里获取到的课程,那么数据就会直接提交到JsonCourseStore。如果你选择这样做,你得先做个备份,假如用户单击取消,你就可以把备份的数据还原回去。另一个做法是从JsonCourseStore里获取用户当前选中的课程,然后克隆一份赋给Course属性,当用户单击确定时,就把Course属性的数据更新过去,而单击取消的话也是什么都不做。这里选择后面那种做法:

代码 35
创建好NewCourseViewModel和EditCourseViewModel两个私有类之后,我们需要考虑一下如何访问它们的实例,办法可能有很多,其中最简单的是在NewOrEditCourseViewModel类里创建两个静态方法,分别用于创建这两个类的实例:

代码 36
有了实例之后,我们就要考虑数据绑定的问题了,这个不难处理,我们总共也只有五个绑定需要创建,你可以参照下表修改NewOrEditCoursePage.xaml的内容:
类型
属性
绑定表达式
TextBlock
Text
{Binding Title}
TextBox
Text
{Binding Course.Name, Mode=TwoWay}
TimePicker
Value
{Binding Course.StartTime, Mode=TwoWay}
TimePicker
Value
{Binding Course.EndTime, Mode=TwoWay}
TextBox
Text
{Binding Course.Location, Mode=TwoWay}
表 4
由于页面之间的切换是通过Navigate方法来实现的(参见代码2),而它又只接受Uri对象作为参数,于是查询字符串就自然而然地成为页面之间传递数据的主要途径了。我们需要的参数有三个:action、day和id,其中action的值有new和edit两种,分别对应新建和编辑两种操作,action可以和day或id搭配使用,但不会同时使用三个参数。我们可以根据这些参数的值创建对应的ViewModel对象,然后把它赋给页面的DataContext属性:

代码 37
现在的问题是,这段代码应该放在哪里?构造函数?还是别的什么地方?这取决于NavigationCacheMode属性的值是什么,如果它的值是Disabled,这意味着页面不会缓存,每次都会创建新的实例,那么我们可以把这段代码放在页面的构造函数里,否则,我们应该把它放在OnNavigatedTo方法里,事实上,任何时候我们来到一个页面,该页面的OnNavigatedTo方法都会被调用,所以放在这里比较保险。你可能会问,为什么CourseTimetablePage页的DataContext属性是在构造函数里设置的?好问题!那是因为CourseTimetablePage页的ViewModel对象由始至终都未曾改变,而NewOrEditCoursePage页的ViewModel对象及与之绑定的Course对象每次都可能不同,页面的缓存可能会导致用户看到"过期"的信息。
当用户单击确定时,将会调用ViewModel对象的Submit方法,然后调用NavigationService对象的GoBack方法返回课程表;而单击取消的话就直接返回:

代码 38
现在,让我们回到CourseTimetablePage.cs,把新建按钮的Click事件处理程序改成下面这样:

代码 39
需要说明的是,Pivot项的标题是"星期X",对应于Course对象的Day属性,因此我们可以把它作为参数通过查询字符串传给NewOrEditCoursePage页。接着,为编辑按钮创建一个Click事件处理程序:

代码 40
最后是删除按钮的Click事件处理程序:

代码 41
你可能会问,为什么执行删除之前不给用户提示一下?嗯,这是个值得考虑的问题,有时候你不提示用户会质问你万一删错了怎么办,有时候你提示了用户也会抱怨这样做很烦,一般而言,仅仅在执行操作之前给用户提示一下是远远不够的,你还需要让用户可以设置以后不再提示,以及告诉用户随时可以在哪里开启提示。不过,你可以尝试实现一个简单的提示,至于可以设置的提示将会在以后的文章里探讨。
现在还缺什么吗?噢,对了,还缺两个菜单项,打开CourseTimetablePage.xaml,切换到XAML模式,在<shell:ApplicationBar></shell:ApplicationBar>里添加两个Application Bar菜单项:

代码 42
它们的Click事件处理程序比较简单,只是分别调用JsonCourseStore的Commit方法和Rollback方法:

代码 43
我已经等不及要按F5了,单击主页中间的按钮打开课程表,单击新建按钮,输入课程名称:

图 46
修改上课时间:

图 47
单击确定返回:

图 48
奇怪了!我刚才明明输入了课程名称,为什么现在没了呢?而且上课时间也没改过来!究竟发生了什么事?
我查看了Silverlight for Windows Phone Toolkit的代码,在DateTimePickerPageBase类的HandleClosedStoryboardCompleted方法里可以看到,当我们设好时间并单击确定时,它会调用NavigationService对象的GoBack方法返回。前面我们提到,任何时候当我们来到一个页面时,该页面的OnNavigatedTo方法就会被调用,现在,我们在NewOrEditCoursePage.cs的OnNavigatedTo方法里设置一个断点,然后从设置时间的页面返回,看看会发生什么事:

图 49
从OnNavigatedTo方法的e参数和NavigationContext对象的QueryString属性不难看出,GoBack方法并没改变导航URI,即查询字符串的action和day两个参数都完好如初,于是第一个条件语句被执行,创建一个全新的ViewModel对象并把它赋给页面的DataContext属性,就像刚刚打开这个页面似的,但是,此时页面的DataContext属性已经有ViewModel对象了呀!这正是问题所在,既然知道了原因,问题就不难解决了,我们只需在执行这块代码之前先判断一下DataContext属性是否为空就行了。
现在按F5重新运行应用程序,并新建一个课程,完了之后你就会在课程表里看到它了:

图 50
慢着!时间的显示格式有问题,我期望它显示为8:10 – 10:00而不是现在这样,怎么办?这个时候就轮到转换器出场了。
首先,创建一个Utils文件夹,在里面添加一个TimeConverter类,并让它实现IValueConverter接口,实现这个接口只需实现两个方法,一个是Convert方法,用于把Course对象的StartTime属性和EndTime属性的值转换为显示在UI上的字符串,另一个是ConvertBack方法,这个方法只在双向绑定时才会派上用场,而这里是单向绑定,所以我们不必为它提供实现:

代码 44
接着在CourseTimetablePage.xaml的资源字典里添加一个TimeConverter对象:

代码 45
然后把那两个TextBlock的Text属性的绑定表达式分别改为"{Binding StartTime, Converter={StaticResource timeConverter}}"和"{Binding EndTime, Converter={StaticResource timeConverter}}"。
现在按F5重新运行应用程序,并新建一个课程,这次时间的显示格式就没问题了:

图 51
选中这个课程,并单击编辑按钮:

图 52
嗯,很好,页面标题和课程信息都正确显示了,修改一下并按确定返回:

图 53
课程信息的更改也正确反映到课程表了。现在,确保课程处于选中状态,单击删除按钮,噢,出错了:

图 54
这个问题好解决,我们只需在使用e.Item之前判断一下它是否为null就行了:

代码 46
现在按F5重新运行应用程序,并新建一个课程,先别删除这个课程,我们需要用它来执行以下测试:
- 单击Application Bar右上角的省略号。此时,Application Bar的菜单项会显示出来。单击"保存所有更改"菜单项,并按两次Back键退出应用程序。从应用程序列表启动应用程序,并进入课程表。此时,刚才新建的课程应该显示在课程表里。选中这个课程,单击编辑按钮,修改课程信息,并按确定返回。此时,课程信息的更改应该反映在课程表上。单击Application Bar右上角的省略号,并单击"撤销所有更改"菜单项。此时,课程表上的课程信息应该还原为更改之前。但是,没有还原!!确保这个课程处于选中状态,单击编辑按钮。此时,我们可以看到课程信息确实已经还原了!!修改课程信息,并按确定返回。此时,我们可以看到新的更改没有反映在课程表上!!确保这个课程处于选中状态,单击编辑按钮。此时,我们可以看到课程信息确实更改了!!单击新建按钮,输入课程信息,并按确定返回。此时,我们在课程表上并没看到刚刚新建的课程!!为什么会这样?从上面的测试不难看出,所有怪事都是单击"撤销所有更改"菜单项之后发生的,而后面五条测试的结果显然在告诉我们课程表页面和JsonCourseStore已经脱节了。沿着这条线索,我们打开JsonCourseStore.cs文件,仔细阅读里面的代码,Rollback方法是直接调用LoadCoursesFromIsolatedStorage方法的,当我们单击"撤销所有更改"菜单项时,它会从独立存储区读取Courses.json文件,并把里面的数据反序列化到Courses属性。慢着!把数据反序列化到Courses属性?这会改变整个集合的引用!换句话说,每次调用Rollback方法时都会重新创建一个集合,而课程表却一直和"过期"的集合关联着,难怪后面几条测试给人的感觉是课程表页面和JsonCourseStore脱节了。
知道症结所在,剩下的事情就好办了,你可以分开实现JsonCourseStore的构造函数和Rollback方法,也可以像我这样,修改LoadCoursesFromIsolatedStorage方法:

代码 47
新版的LoadCoursesFromIsolatedStorage方法不再把数据直接反序列化到Courses属性,而是把Courses属性清空,再把数据逐条添加进去。至于Courses属性的初始化则挪到构造函数了。需要说明的是,ObservableCollection类没有ForEach方法,这是我自己创建的扩展方法,你也可以直接使用foreach语句实现。
现在按F5重新运行应用程序,并重新执行一次上面的测试,嗯,这次没问题了,最后,选中这个课程,单击删除按钮,好了,课程已经删除了。在整个操作的过程中,还有两个地方我认为需要改善一下的。第一个是当用户单击"保存所有更改"菜单项时,应用程序应该在完成操作之后提示一下,否则用户可能会有点茫然。另一个是从NewOrEditCoursePage页返回CourseTimetablePage页时总是看到今天的课程,当我们新建或编辑的课程不是今天的课程时可能引起不必要的疑惑。试想一下,我在新建星期二的课程,当我输入完课程信息并按确定返回时,我看到星期一的课程,因为今天是星期一,这时我的感觉会是怪怪的,比较符合直觉的做法应该是返回时显示星期二的课程,而不是盲目地显示今天的课程。这两个改进的实现就当课后作业留给你吧,对于第二个问题,我可以给个提示,研究一下OnNavigatedTo方法和OnNavigatingFrom方法,应该难不了你吧?
菜单·样品菜色
还差什么呢?噢,对了,目前我们是通过主页中间的按钮打开课程表的,这显然不好意思拿出来见人,我们还是创建一个正式的主菜单吧。现在让我们切换到Expression Blend,如果你的Expression Blend已经关闭了,你可以右击Solution Explorer的MainPage.xaml,然后选择Open in Expression Blend:

图 55
菜单的制作方式有很多种,这里选用ListBox:

图 56
菜单创建好后,右击里面的"课程表",然后选择Navigate To\CourseTimetablePage,此时,Expression Blend会为TextBlock添加一个NavigateToPageAction行为,但这个行为默认是关联到MouseLeftButtonDown事件的,这样,当用户按下"课程表"还没松开手就打开课程表了,而我们的习惯是松手之后才执行相关的操作,为了实现这个效果,我们可以把关联事件改为MouseLeftButtonUp:

图 57
菜单有了,但样品菜色却被我们弄没了,如果你现在打开CourseTimetablePage.xaml,你将会看到此番情景:

图 58
之前我们手动创建两个Pivot项,然后把它们分别绑到两个示例数据源,但现在Pivot项是通过数据绑定动态创建的,之前那些示例数据源就排不上用场了,既然用不了,那就删了吧,打开Data面板,右击数据源,然后选择Delete data source:

图 59
此时,Expression Blend会把SampleData文件夹里的相关文件一并删除。
在Expression Blend里,没有设计时数据会为调整控件模板和样式带来很大不便,下次你去找设计师调整一下用户界面,他们很可能会对你大骂一顿,那么,有没有办法让它再次显示设计时数据呢?答案是有的,而且不止一种,下面来看其中一种。我们知道,CourseTimetablePage页使用了数据绑定,而绑定表达式只提及了绑定的属性,并未提及属性所在的类型,换句话说,只要属性名字能够匹配,示例数据就应该绑得上去。首先,准备一份XML,注意匹配绑定表达式的属性名字:

代码 48
接着,在Data面板上把它导入(参见图15),注意,这次要把Enable sample data when application is running选项去掉:

图 60
导入之后在Data面板上把自动生成的ColumnCollection和CourseCollection分别改为Columns和Courses,以便匹配绑定表达式的属性名字:

图 61
现在,把Timetable拖到Pivot控件上,此时你会看到鼠标下方有个小提示,告诉你Expression Blend将会把Pivot控件的DataContext属性绑到CoursesSampleDataSource:

图 62
当你松开鼠标时,就会看到课程名称了,但上课时间、下课时间和上课地点却显示不出来:

图 63
这是为什么呢?如果你把CourseTimetablePage页面关闭,然后重新打开它,你就会看到问题所在了:

图 64
这个异常明确地告诉我们调用转换器时抛出InvalidCastException。还记得吗,我们的转换器会把传入对象强制转换成DateTime对象,然后调用它的ToShortTimeString方法,但Expression Blend为示例数据生成的StartTime属性和EndTime属性是字符串类型的!明白为什么会这样,问题就不难解决了:

代码 49
重新编译项目,然后重新打开CourseTimetablePage页,你就会看到设计时数据了:

图 65
好了,总算对设计师有个交代了。
下课了……

