|
作业本
上节课布置的作业有做吗?没人吭声啊,看来大家都忘了哦,没事,我们这次弄个作业本出来,大家就有地方记作业了。在开始设计应用程序之前,我们先来看看通常的作业本是怎样记作业的:
图 1
从上图可以看到,作业本有点像日记本,每次记录时都会写下当天的日期,每天的作业又会根据课程进行归类。慢着!我怎么知道这些作业什么时候交?一般情况下,中小学生的作业都是第二天上课时交的,但大学生就不同了,他们的作业可能第二天交,也可能一周之后交,有时甚至几周之后才交,更重要的是,不同的作业可能在不同的时间交。换句话说,我们的应用程序还需要支持记录交作业的时间。此外,每当完成一项作业,我们可以在旁边做个记号,这样,当我们打开作业本时,即使作业再多也能马上知道哪些还没做完。
现在,用Visual Studio打开项目,在Models文件夹里创建一个Assignment类,和上节课的Course类一样,它也需要实现INotifyPropertyChanged接口。由于我们有很多类都需要实现INotifyPropertyChanged接口,为了避免不必要的重复,你可以考虑创建一个类专门实现这个接口,然后让有需要的类继承这个类。这个需求似乎比较常见,因此Prism提供了一个NotificationObject类,我们只需继承它就行了:
代码 1
继承之前别忘了引用Bin/Phone/Microsoft.Practices.Prism.dll类库和Microsoft.Practices.Prism.ViewModel命名空间哦。根据前面的讨论,Assignment类应该包含以下属性:
属性名字 | 属性类型 | 备注 |
Id | Guid | 唯一标识 |
CourseName | string | 课程名称 |
StartDate | DateTime | 创建日期 |
DueDate | DateTime | 截止日期 |
Content | string | 作业内容 |
IsCompleted | bool | 完成状态 |
定制数据模板
首先是定制分组标题的数据模板,右击LongListSelector控件里的任何地方,选择Edit Additional Templates/Edit GroupHeaderTemplate/Create Empty:
图 5
在弹出的Create DataTemplate Resource对话框里输入模板名字,然后按OK关闭对话框:
图 6
进入模板的编辑状态之后,你会看到一个空的Grid,从Tools面板把一个TextBlock拖到Grid里,确保TextBlock处于选中状态(而不是编辑状态),单击Text属性右边的小正方形,并选择Data Binding:
图 7
在弹出的Create Data Binding对话框里选中Use a custom path expression,并在旁边的编辑框里输入Key:
图 8
为什么输入Key呢?因为通过LINQ的group XXX by YYY创建的分组对象实现了IGrouping<TKey, TElement>接口,而这个接口有个Key属性保存了分组的依据——创建日期,也就是这里需要的分组标题了。
当你按OK关闭对话框之后,你将会看到:
图 9
奇怪了!我们明明提供了示例数据啊,而且数据绑定也没弄错啊,为什么TextBlock没有任何显示?仔细观察Text属性下面的DataContext属性:
图 10
此时的值应该是分组对象而不是AssignmentListViewModel对象啊!我怀疑LongListSelector控件没有正确处理DataContext在设计时的传递(bug?),导致Expression Blend无法获取正确的数据。既然这样,我们只好再弄点示例数据了,单击Text属性右边的编辑框,选择Reset,然后把Text属性的值改为"2010/11/29"。接着,在Objects and Timeline面板上选中Grid,单击Background属性右边的小正方形,并选择System Resource/PhoneAccentBrush:
图 11
此时,你的Artboard应该是这样的:
图 12
退出模板的编辑状态,保存所有修改,然后重新编译项目,好了之后就能看到分组标题了:
图 13
不要奇怪分组标题都是"2010/11/29",这是我们刚才为了编辑的方便硬编码上去的结果,暂时忍耐一下吧。
接下来是列表项的数据模板,右击LongListSelector控件里的任何地方,选择Edit Additional Templates/Edit ItemTemplate/Create Empty,在弹出的Create DataTemplate Resource对话框里输入模板名字(itemTemplate),然后按OK关闭对话框。现在,我们要思考的问题是,如何更好地显示作业数据呢?回顾表1,Id属性为了便于应用程序搜索Assignment对象而创建的,用户并不需要知晓它的存在,所以我们不必把它呈现在用户面前,Pivot项的标题已经显示了CourseName属性,分组标题也显示了StartDate属性,剩下的就是DueDate、Content和IsCompleted三个属性了,那么我们应该如何显示这三个属性?此时,我的脑子里浮现出的第一个想法是这样的:
图 14
整个Grid分为两个Column,左边是作业内容,自动换行,右边从上到下分别是截止日期的月、日和完成状态,一般情况下,创建日期和截止日期的年份都是一样的,所以我们没有必要提供重复的信息,即使碰到跨年的情况,用户也不会因为缺少年份而感到疑惑,除非有个老师布置了一个跨越两年或以上的作业。想到这里,我的脑子里突然闪出一个问题,表示完成状态的TextBlock能否去掉,并以其它方式表达这个信息呢?此时,我的脑子里迅速浮现出各种各样的图标,但是,还有更好的方式吗?颜色,突然这个词儿从我的脑子里掠过,一般而言,与文字相比,我们的大脑对颜色的反应更快更准。有鉴于此,我把列表项的模板改成这样:
图 15
右边部分将会根据作业的不同状态显示不同底色。退出模板的编辑状态,保存所有修改,然后重新编译项目,好了之后就能看到效果了:
图 16
显然,字体的大小、控件之间的间距还不能让人满意,我们需要调整一下,这个过程可能有点反复和枯燥,但这却是我们体贴用户的重要途径,我们不但要让用户的眼睛感到满意,还要让用户的手指感到满意(别忘记我们开发的是触屏应用程序哦),下面是我调整之后的效果:
图 17
现在,我们可以再次进入模板的编辑状态,为对应的控件设置数据绑定了,做法和前面为分组标题设置数据绑定的一样(图7和图8),各个控件对应的自定义路径表达式如下图所示:
图 18
好了之后就可以看到我们前面准备的示例数据了:
图 19
噢,分组标题!我希望只显示日期,而且是符合中国区域设置的短日期格式,还有月份的显示,我希望是"十一月"而不是"11"。
这个时候又轮到转换器出场了。首先,切换到Visual Studio,在Utils文件夹里创建下面两个类:
代码 12
代码 13
需要说明的是,因为我们的绑定是单向的,所以没有必要实现ConvertBack方法。接着,在AssignmentBookPage.xaml的资源字典里创建它们的实例:
代码 14
看到这里,你可能会问,这两个转换器的Convert方法都使用了culture这个参数,但我们没有直接调用Convert方法啊,那我们怎么把这个参数传给它?这可以通过设置绑定表达式的ConverterCulture属性做到,现在,把那两个TextBlock的Text属性的绑定表达式改为"{Binding Key, Converter={StaticResource dateConverter}, ConverterCulture=zh-CN}"和"{Binding DueDate.Month, Converter={StaticResource monthNameConverter}, ConverterCulture=zh-CN}"。
剩下的就是截止日期的底色了,既然转换器可以把DateTime对象转换成字符串,它也应该可以把Assignment对象转换成SolidColorBrush对象,不过,在创建这个转换器之前,我们得先弄清楚什么状态对应什么底色。前面我们说过,作业本的主要目的是让学生对要做哪些作业一目了然,而"未完成"的作业里可能存在一些已经过了截止日期的,这类作业需要马上处理,所以我们应该单独为这类作业设置一种底色,以便用户及时知晓并采取行动。假设这三种状态及其对应的底色如下表所示(你也可以换成其它底色):
状态 | 底色 |
已逾期 | Red |
未完成 | #FF1BA1E2 |
已完成 | Green |
插曲 #1
究竟发生了什么事?示例数据和绑定表达式应该都没问题啊,否则Expression Blend和Visual Studio的设计器也不会正常显示,那么问题到底出在哪里呢?突然,一个想法在我的脑子里闪过,如果我在DateConverter类的Convert方法里设个断点,你觉得会怎么样?试一下吧……结果是,没有到达这个断点,换句话说,Convert方法根本没被调用!这种情况有点像数据绑定找不到分组对象的Key属性,比如说,我故意把绑定表达式的Key改为Key1,结果Expression Blend的设计器就变成这样了:
图 24
我们知道,分组对象实现了IGrouping<TKey, IElement>接口,因此Key属性肯定存在,否则编译器会报错,那么,什么情况下这个属性是不可见的,或者说,有什么办法可以让它不可见?想到这里,一个词儿突然在我的脑子里冒出来——显式接口实现!如果Key属性是显式实现的,仅当变量的类型是IGrouping<TKey, IElement>时Key属性才是可见的。看到这里,你可能会说,Silverlight不可能直接调用分组对象的Key属性,它应该是通过反射获取这个属性的。没错,当我们在绑定表达式里以字符串的形式给出属性路径,PropertyPathConverter对象将会把这个字符串转换成PropertyPath对象,那么,PropertyPath对象又是如何找到对应的属性呢?在微软公开的.NET Framework 4.0源代码里,我找到了PropertyPath类的实现,里面有个GetPropertyHelper方法负责获取指定的属性:
代码 17
如果Key属性是显式实现的话,GetProperty方法就会返回null!换句话说,数据绑定和显式实现的属性一起工作的话会出问题。那么,group XXX by YYY返回的分组对象是不是显示实现Key属性的呢?我们知道,使用group XXX by YYY实质上就是调用Enumerable类的GroupBy方法,经过一番查找,我发现它返回的分组对象就是Lookup类内部的Grouping类的实例,但Grouping类的Key属性是隐式实现的,有趣的是,Key属性上方有一段注释:
代码 18
除了Key属性之外,Grouping类的其它属性都是显式实现的,我猜Key属性原来也是显式实现的,后来由于数据绑定的问题才改为隐式实现。
这些代码是WPF 4.0的,而Key属性上面的注释也明确提到了WPF,这是不是说Key属性的值在WPF里可以正确显示?我们可以设计一个简单的实验来验证一下:
- 创建一个ListBox。
- 定制ListBox的ItemTemplate,里面只放一个TextBlock。
- 把TextBlock的Text属性设为"{Binding Key}"。
- 通过GroupBy方法创建分组对象的集合,并把它绑到ListBox的ItemsSource属性。
- 按F5。
我分别在WPF 4.0、SL 4.0和SL for WP7上执行这个实验,发现只有WPF 4.0能够正确显示Key属性的值,其它两个的ListBox是一片空白的。我怀疑SL的分支是在这个问题得到修复之前创建的,但我没有代码证实这个猜想。
还有一个问题我没弄明白的,为什么设计器能够正确显示而程序真正运行的时候却不能?难道设计器对显式实现的属性有什么特别的照顾?为了验证这个猜想,我又做了一个实验,我不直接返回分组对象,而是通过下面这个Grouping类包装一下再返回:
代码 19
结果,设计器也不显示了……我不知道为什么设计器能够正确显示GroupBy方法返回的分组对象的Key属性,这里面肯定有些东西是我不知道的,如果你知道原因,或者先我一步找到原因,那你一定要告诉我哦!
连接前端和后端
既然显式实现的属性会对数据绑定造成不良影响,那我们就换成隐式实现吧。首先,在ViewModels文件夹里创建AssignmentGroupViewModel类,并让它继承ObservableCollection<Assignment>类:
代码 20
为什么要继承ObservableCollection<Assignment>类呢?前面说过,LongListSelector控件硬性规定分组对象至少实现IEnumerable接口,不过,要想获得更好的效果,仅仅实现IEnumerable接口是不够的,LongListSelector控件通过内部的GetItemsInGroup方法来获取分组内容:
代码 21
从上面代码不难看出,如果分组对象实现了IList接口,那么每次获取分组内容时都会免掉一次遍历。此外,我们还希望当分组内容发生改变时,比如新建/删除一项作业,分组对象能够自动通知LongListSelector控件做出相应的更新,为了实现这个效果,分组对象需要实现INotifyCollectionChanged接口。毫无疑问,能够一次过满足我们所有要求的最简单做法就是继承ObservableCollection<Assignment>类了。
看到这里,你可能会问,IGrouping<TKey, TElement>接口不用实现吗?不用,LongListSelector控件没有规定分组对象必须实现这个接口,我们只需简单地创建一个Key属性,配合绑定表达式里的属性路径就行了:
代码 22
需要说明的是,ObservableCollection<Assignment>类也实现了INotifyPropertyChanged接口,所以我们可以直接使用它的OnPropertyChanged方法。
接下来是分组对象的初始化,这个过程的主要任务有两个:
- 查询数据源,把满足条件的作业内容添加到自身。
- 监听数据源,把满足条件的内容更改反映到自身。
执行这两个任务的前提是有个可用的数据源,我们可以仿效课程表的做法,在App类里通过静态属性提供JsonDataStore<Assignment>对象:
代码 23
有了数据源我们就可以着手执行第一个任务了:
代码 24
需要说明的是,这里把判断条件单独提取出来了,因为执行第二个任务时还要用到:
代码 25
需要说明的是,e参数的NewItems和OldItems两个属性看起来好像可能包含多个元素,但事实上它们只会包含一个,因为NotifyCollectionChangedEventArgs类的构造函数限制了这个可能,不过这个限制仅存在于Silverlight的现有版本(SL3、SL4、SL for WP7)。另外,这里使用了Lambda语句来创建CollectionChanged事件的处理程序,虽然你也可以通过一个单独的方法做到,但使用Lambda语句可以利用闭包的特点重用前面的判断条件,当然,使用匿名方法的语法也是可以的。
还差什么呢?噢,对了,LongListSelector控件内部会调用分组对象的Equals方法进行判等,我们可以重写AssignmentGroupViewModel类的Equals和GetHashCode两个方法,使之根据Key属性来判等以及获取哈希值。这个任务留给你当课后作业吧。
既然分组对象的类型改了,那AssignmentListViewModel类的AssignmentGroups属性也得做出相应的调整吧:
代码 26
由于AssignmentListViewModel类对应用户界面上的Pivot项,我们还需要给它创建一个Title属性:
代码 27
有了这些准备,我们就可以着手实现AssignmentListViewModel类的构造函数了:
代码 28
看到这里,你可能会说,这条LINQ语句看起来有点复杂嘛!其实不然,想想看,我们的最终目的是什么?创建分组对象并把它们添加到AssignmentGroups属性。那创建分组对象需要什么条件?课程名称和创建日期。课程名称已经有了,创建日期来自哪里?来自数据源。那我们对创建日期有些什么要求?我们只要和指定课程相关的,而且不要重复的。现在,你再看看上面这条LINQ语句,从上往下看,有没有觉得它像下面这条"流水线"?
图 25
前面我们说过,当用户新建一项作业时,它会自动添加到"今天"的分组里,但如果"今天"的分组还没创建出来呢?那AssignmentListViewModel类就应该为这项新的作业创建"今天"的分组,并把它添加到AssignmentGroups属性:
代码 29
当用户删除一项作业时,如果这项作业是所属分组的唯一一项作业,LongListSelector控件会自动隐藏这个分组。而当用户撤销所有更改时,AssignmentListViewModel类得把AssignmentGroups属性清空。
到目前为止,AssignmentBookPage页里的每个组成部分都有对应的ViewModel类了,现在是时候为它创建一个了。在ViewModels文件夹里创建一个AssignmentBookViewModel类,并创建一个AssignmentLists属性:
代码 30
AssignmentBookViewModel类的任务是读取课程表的数据,然后创建对应的AssignmentListViewModel对象:
代码 31
看到这里,你可能会问,为什么这里不用监听数据源的更改?如果你要编辑课程表,一定要进入课程表的用户界面,一旦离开课程表的用户界面,课程表的数据就会冻结下来,换句话说,在AssignmentBookViewModel对象的整个生命周期里,课程表的数据是稳定的。
现在,我们可以着手处理数据绑定了。打开AssignmentBookPage.xaml文件,切换到XAML模式,在页面的资源字典里添加两个数据模板:
代码 32
接着,把现有的Pivot项删除,并在Pivot控件上设置数据模板和数据绑定:
代码 33
最后在AssignmentBookPage的构造函数里创建一个AssignmentBookViewModel对象,并它把赋给DataContext属性:
代码 34
好了,不知不觉又到看效果的时候了!按F5运行应用程序:
图 26
单击"课程表"菜单项进入课程表,新建两个课程,保存,然后按Back键返回主菜单:
图 27
在主菜单里单击"作业本"菜单项进入作业本,此时,你会看到作业本已经为刚才创建的两个课程准备了两个Pivot项:
图 28
只是作业本上没有任何内容,也没有任何途径可以添加内容……
编辑作业本
作业本支持的操作和课程表一样,包括新建、编辑、删除、保存所有更改和撤销所有更改,其中,新建和保存以ApplicationBarIconButton的方式放在Application Bar上,撤销所有更改以ApplicationBarMenuItem的方式放在Application Bar上,而编辑和删除则放在上下文菜单里:
图 29
为什么这样安排?当老师布置作业时,我们会掏出作业本记下作业,下课之后,当我们要做作业时,我们会掏出作业本看看要做哪些作业,换句话说,新建、保存和显示作业内容这三个功能已经可以满足用户绝大多数的需求了。新建和保存作为最常用的两个操作自然应该放在最显眼的位置,删除和撤销所有更改这两个操作基本上不会用到,至于编辑,一般情况下我们只是用来修改作业的完成状态,由于编辑和删除是针对特定作业的,我们把它们放在上下文菜单里,当用户长按某项作业时将会显示出来,而撤销所有更改则隐藏在Application Bar的菜单里。
接着,创建一个Windows Phone Page,并把它命名为NewOrEditAssignmentPage.xaml,这个页面会在用户单击Application Bar上的新建按钮或者上下文菜单上的编辑菜单项时显示。完了之后把ApplicationTitle的Text属性值改为"作业本",但PageTitle保留原样:
图 30
那么,这个页面应该放些什么控件呢?想想看,创建一个完整的Assignment对象需要哪些数据?Id是自动生成的,课程名称可以从上下文获取,创建日期可以从DateTime的Today属性获取,剩下的就是截止日期、作业内容和完成状态了。截止日期可以使用SL for WP Toolkit的DatePicker控件,作业内容可以使用TextBox控件(上面的标题需要额外添置TextBlock控件),而完成状态则可以使用CheckBox控件:
图 31
看到这里,你可能会问,为什么不把其它信息也显示出来呢?你可以这样做,但是,请注意,这个页面的主要目的是收集而不是显示信息,我们应该尽可能简化用户的输入过程,在这里放置控件显示其它信息,尤其是可编辑的控件,可能会耗费用户额外的注意力,比如说,有些用户会下意识地检查所有数据是否输入正确。创建作业的过程应该是既简单又快速的,而我们也希望用户能有这样的感受,但耗费用户额外的注意力意味着增加整个操作过程的时间,从而可能导致用户的感受和我们期望的刚好相反,这是我们不希望看到的。
ViewModel类方面,我们将会仿效课程表的做法,创建NewOrEditAssignmentViewModel、NewAssignmentViewModel和EditAssignmentViewModel三个类:
图 32
我们知道,NewOrEditAssignmentPage页有两个模式,一个是新建模式,另一个是编辑模式,前者对应NewAssignmentViewModel类,而后者则对应EditAssignmentViewModel类。当用户新建一项作业时,NewAssignmentViewModel类可以从DateTime的Today属性获取创建日期,但它没法获取课程名称,所以我们需要通过参数传给它:
代码 35
为什么DueDate属性也要设置呢?想想看,如果我们不给它设置一个值,由于DateTime是值类型,将被自动初始化为"1/1/0001",当用户看到页面上的DatePicker控件显示这样一个日期可能会感到不友好,再者,老师布置下来的作业一般不会当天交(课堂作业除外),而第二天交的情况则比较常见(当然,计算下一个"上课日"可能更加合理)。而当用户编辑一项作业时,EditAssignmentViewModel类将会从数据源里查找这项作业的数据,但前提是我们把作业的Id告诉它:
代码 36
需要说明的是,Assignment类的Id属性是只读的,而Assignment类原来的构造函数会在每次调用时创建一个新的Id,这导致了我们无法使用现有的Id,所以我们需要在Assignment类里添加下面这个构造函数:
代码 37
创建好ViewModel类之后,我们就可以着手处理它们和NewOrEditAssignmentPage页之间的关联了。首先是设置数据绑定,需要设置的控件以及对应的绑定表达式如下表所示:
描述 | 类型 | 属性 | 绑定表达式 |
页面标题 | TextBlock | Text | {Binding Title} |
截止日期 | DatePicker | Value | {Binding Assignment.DueDate, Mode=TwoWay} |
作业内容 | TextBox | Text | {Binding Assignment.Content, Mode=TwoWay} |
完成状态 | CheckBox | IsChecked | {Binding Assignment.IsCompleted, Mode=TwoWay} |
插曲 #2
究竟发生了什么事?是数据没有添加进去?是事件通知没有发出?还是出现线程安全的问题?我调试了一下,数据已经正确添加进去了,事件通知也正确发出去了,所有操作都在UI线程里执行,而且没有出现并发问题,那么问题到底出在哪里呢?
带着这个疑问,我从codeplex.com上下载了SL for WP Toolkit的最新代码(Change Set 57505),然后调试进去看看。在调试的过程中,我发现每次从NewOrEditAssignmentPage页返回AssignmentBookPage页时,LongListSelector控件都会调用Balance方法,但每次都会"跳过"本应执行的大部分代码,一开始我没怎么留意,觉得这个方法一下子就返回实在太神奇了,仔细观察,原来它是通过第一个if里的return悄悄返回的:
代码 43
难怪LongListSelector控件什么也没显示,因为Balance方法后面那些负责调整显示的代码一句都没执行。为什么会这样?关键在于IsReady方法,因为它每次都返回false。当我单步进入IsReady方法时,发现_itemsPanel和ItemsSource都不为null,但ActualHeight的值却为0.0,从而导致IsReady方法返回false:
代码 44
为什么会这样?这是因为,当我们打开NewOrEditAssignmentPage页时,由于AssignmentBookPage页暂时无需显示,Silverlight会把它从主对象树移除,于是ActualHeight会被"清零",当我们从NewOrEditAssignmentPage页返回时,Silverlight需要重新测量每个控件的大小(包括页面本身),并安排它们的位置,ActualHeight的值为0.0意味着Silverlight还没完成布局处理的工作,换句话说,LongListSelector控件还没准备好,IsReady方法返回false是正确的。奇怪的是,每次我们从NewOrEditAssignmentPage页返回时,Balance方法里的IsReady方法没有一次返回true的,这可能意味着Balance方法的调用时机不对,那什么时候调用才对呢?控件加载完毕的时候,即Loaded事件触发的时候,那么,LongListSelector控件在Loaded事件触发的时候做了些啥呢?其实没什么,只是简单地把_isLoaded设为true,然后调用EnsureData方法:
代码 45
这么看来,问题的关键就在于EnsureData方法有没有正确调用Balance方法了。我们来看看EnsureData方法的代码:
代码 46
FlattenData和Balance是两个很重要的方法,前者负责从ItemsSource把数据初始化到_flattenedItems,而后者则负责确定哪些数据需要显示以及如何显示。显然,当我们从NewOrEditAssignmentPage页返回时,如果我们创建了作业,if里面的语句是不可能执行的,因为_flattenedItems里面包含了我们的作业!?这听起来很别扭,不是吗?毫无疑问,LongListSelector控件没有考虑我们的情况,即打开一个另一个页面操作数据源,这是不应该的,你不可能指望我们把所有事情都放在同一个页面里处理吧?
既然知道了原因,问题就不难解决了,把LongListSelector控件的Loaded事件处理程序改成下面这样:
代码 47
看到这里,你可能会问,_isLoadedRaisedBefore是干嘛的?我们知道,第一次进入AssignmentBookPage页和从NewOrEditAssignmentPage页返回时都会触发Loaded事件,这是两种需要区别处理的情况,因为Balance方法里包含了重设_resolvedFirstIndex和_resolvedCount的代码(参见代码43),如果我们在后面那种情况下执行这行代码,LongListSelector控件的显示就会乱掉,因为它计算不出正确的显示索引,_isLoadedRaisedBefore的存在就是为了防止这种情况的发生。接着,在Balance方法里用if把重设_resolvedFirstIndex和_resolvedCount的那行代码包围起来:
代码 48
值得提醒的是,每次调用FlattenData方法都会重设_flattenedItems,这对于从NewOrEditAssignmentPage页返回的情况来说是没有必要的,所以Loaded事件处理程序里的FlattenData方法需要放在if里,否则,使用ObservableCollection就会变得毫无意义了。
改好之后,编译一下。注意,如果你是通过MSI安装SL for WP7 Toolkit的话,你需要先在项目属性里修改一下版本再编译,否则待会重新添加引用的时候Visual Studio会自作聪明的引用原来那个dll文件,因为MSI在注册表里做了手脚。
一切准备就绪之后就可以按F5了。单击Application Bar上的新建按钮打开NewOrEditAssignmentPage页:
图 36
输入作业内容,然后按确定返回:
图 37
噢,终于看到我的作业啦!
编辑作业本·续
回到作业本的操作,接下来我们要实现编辑和删除两个操作。前面提到,我打算把它们放在上下文菜单里,那么,如何创建上下文菜单?非常简单,我们可以使用SL for WP Toolkit的ContextMenu控件:
代码 49
正如你所看到的,ContextMenu控件只需嵌入目标对象就能工作了,非常方便。
接下来的问题是如何实现它们的事件处理程序。我们知道,这两个操作有一个共同点,就是要获取用户当前选中的作业,怎么获取呢?有些同学可能会建议,在AssignmentListViewModel类里添加一个SelectedAssignment属性,并为它和LongListSelector控件的SelectedItem属性设置双向绑定,这样,一旦用户选中某项作业,我们就可以通过SelectedAssignment属性获取作业的Id了。你可以这样做,不过,这个做法会带来一个小小的问题,就是用户在长按某项作业之前得先单击一下。什么意思?我们知道,手机没有鼠标右击的概念,我们是通过长按(Touch and Hold)打开上下文菜单的,但从触摸手势的角度来看,长按和单击(Tap)是两个不同的触摸手势。LonglistSelector控件只会在单击的时候设置SelectedItem属性,它不处理长按,所以当我们通过长按打开上下文菜单时,SelectedItem属性可能为null或者之前选中的其它作业,前者会引发异常,而后者则会为用户带来困扰。为了避免这些问题,要么我们再次修改LongListSelector控件的代码,要么用户不得不执行一步额外的操作,显然,这都不是什么好办法,还有没有别的选择?
当然有!你知道吗,DataContext属性是一个很特别的属性,子元素可以从父元素那里继承这个属性的值,对照代码49来看,这意味着MenuItem的DataContext和Grid的有着相同的值,而这个值正是我们苦苦寻找的作业!换句话说,只要我们获取到用户单击的MenuItem对象,就可以通过它的DataContext属性获取用户想要操作的作业。我们知道,事件处理程序的第一个参数就是引发该事件的对象,于是我们可以通过这个参数来访问MenuItem对象:
代码 50
这样,我们既不需要在AssignmentListViewModel类里添加一个SelectedAssignment属性,也不需要修改LongListSelector控件的代码,更不需要委屈用户执行额外的操作,真是一举三得啊!
现在只剩一个操作了——撤销所有更改,我相信这对于你来说不是问题,所以我决定把它留给你当课后作业。
好了,又到看效果的时候了!按F5运行应用程序,新建三项作业:
图 38
长按第三项作业,你会看到这项作业以外的所有东西都缩小了,给人一种向后移动的感觉,这个动画生动地突出了正在操作的作业以及上下文菜单,不过,不知道是不是动画的bug,第三项作业的截止日期上面有个瑕疵(试了几次都是这样):
图 39
单击编辑将会打开NewOrEditAssignmentPage页,修改一下截止日期:
图 40
然后按确定返回,你会看到刚才修改的截止日期:
图 41
接着,长按第二项作业(Textbook. P20. Ex 2),并选择删除:
图 42
作业成功删除。但是,如果你尝试删除(剩下的)第二项作业,你会发现它还在那里!为什么!?我调试了一下,发现此时MenuItem对象的DataContext属性的值居然是已故的前任第二项(Textbook. P20. Ex 2),而不是我们期望的现任第二项(Textbook. P21. Ex 3)!因为前任第二项已被删除,所以Remove方法不会触发CollectionChanged事件,LongListSelector控件自然不会更新显示。如果你现在尝试删除第一项作业(既是前任也是现任),你会成功的,但是,在删除之后,如果你再次尝试删除剩下的唯一一项作业,你会发现此时MenuItem对象的DataContext属性的值变成刚故的前任第一项(Textbook. P10. Ex 9, 10)!从此以后,剩下的唯一一项作业就再也删除不了了,除非你返回MainPage页重新打开AssignmentBookPage页。由于编辑操作采用了相同的实现思路,如果一项作业删除不了,那么它也编辑不了。
究竟发生了什么事?是ContextMenu控件的bug吗?我另外创建了一个新的项目,在同等条件下,分别在ListBox和LongListSelector上测试了ContextMenu,结果,Listbox一方表现正常,而LongListSelector一方问题依旧,有趣的是,即使不用打开新的页面,结果还是一样。这让我不得不再一次怀疑是LongListSelector控件的问题。
我重新运行应用程序,然后单步执行第一次删除的整个过程。在这个过程里,我发现一个很奇怪的事情,当我删除第二项时,LongListSelector控件先把第三项的ContentPresenter和Assignment分离开来,并把分离出来的ContentPresenter推入内部的_recycledItems(类型为Stack<ContentPresenter>),接着对第二项做相同的事,然后把第二项从_flattenedItems里删除,最后重新关联第三项的ContentPresenter和Assignment,问题就出现在最后一步,它居然直接使用_recycledItems顶部的ContentPresenter,换句话说,它把第二项的ContentPresenter和第三项的Assignment关联了!见鬼!此时,如果我删除第一项的话,它会把第一项的ContentPresenter和第三项的Assignment关联!从这里不难看出,它应该在关联之前把_recycledItems顶部那个垃圾扔掉!既然知道了原因,问题就不难解决了,在OnRemove方法的相应地方加上红框里面那句:
代码 51
重新编译所有东西,然后运行应用程序,这次没问题了。
写完这篇文章之后,我的第一感觉是LongListSelector控件远未达到产品级别的质量,它的问题导致我无法专注于应用程序本身的功能设计和实现,如果你是本着学习和研究的态度去用它,那没问题,如果你想用它来做产品,那你就要做好心理准备了。不管怎样,这次我还是学到了不少东西。LongListSelector控件的补丁我已经提交到codeplex.com了,在官方发布修正版本之前,我只能使用自己修改的版本了→_→
下课了……
it知识库:WP7有约(二):课后作业,转载需保留来源!
郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。