WP7有约(三):课堂重点
Written by Allen Lee
Crawling in my skin, these wounds they will not heal. Fear is how I fall, confusing what is real.
– Linkin Park, Crawling
记笔记
俗话说:好记性不如烂笔头。当然,这并不是说我们的脑子不好使,也不是叫我们不要用脑子记东西,而是提醒我们解放脑力,让大脑从事更有价值的思考。因此,这节课我们将会创建一个笔记本,用来记录课堂重点,但是,我们需要什么样的笔记本呢?我曾经在《你的灯亮着吗?》里读到这样一句话:如果某人能够解决这个问题,但是他本人却不会遇到这一问题时,那么你们首先要做的就是让他也感受到这个问题。最近公司来了一批校招生,我找了个机会混进去听了一节入职前的技术培训,我想知道在课堂上把手机掏出来记笔记是一种什么样的感觉。
在课堂上,每当我想记点什么时,就会不自觉地拿起纸笔而不是手机,而且,用手机记笔记远没用纸笔来得随意自如。随后,我找了一些大学生和中学生,分别了解一下他们记笔记的情况,结果发现,他们记笔记的方式真是多种多样,有的直接记在书上,有的记在专门的笔记本上,有的记在练习册上,有的记在卷子上,有的甚至用手机把老师的板书直接拍下来……不难看出,他们的做法是怎么方便就怎么记,就目前而言,企图用一个手机应用来取代他们现有的做法显然是不现实的,也没必要,用户有权选择他们认为适合的做法,而我们的职责只是提供必要的帮助和支持。
那么,我们可以提供什么样的帮助和支持呢?想想看,现有的自由零散的做法会导致什么问题呢?最直接的影响是很难快速找到想要的内容,因为它们可能遍布各处,这种时候要是有个索引或者目录什么的就好了……Bingo!我们可以创建一个应用,帮助用户建立这个索引,虽然用户也可以另外找本小册子建立索引,但我们可以通过一个标签系统帮助用户快速找到相关的内容。这样,用户既可以保留现有的自由的记笔记习惯,又可以获得新的有序的管理效果。那么,用户应该在何时以及如何建立这个索引呢?
当然是越早越好!比如说,用户可以在每晚做完作业之后稍稍整理一下笔记,然后为它们创建一些条目并贴上标签。用户不必为所有笔记创建条目,可以挑选重要的来创建,这个过程本身就可以加深对知识的理解和巩固对知识的记忆。至于条目的内容,用户可以引用课本或者老师板书的原话,也可以用自己的话来概括复述,还可以直接引用课本或者练习册的页码和位置(段落、题号或者标记)等等,这个过程可以帮助用户熟悉如何根据条目的内容找到对应的笔记。
现在,用Visual Studio打开项目,在Models文件夹里创建一个Note类,并让它继承NotificationObject类:
代码 1
根据前面的讨论,Note类应该包含以下四个属性:
属性名字
属性类型
属性描述
Id
Guid
唯一标识
Course
string
课程名称
Content
string
笔记内容
Tags
string
笔记标签
表 1
其中,Id是只读属性,根据上节课的经验,我们需要给它创建以下两个构造函数:
代码 2
它们分别用于新建和编辑两种情景。Course、Content和Tags三个属性的set访问器均需调用RaisePropertyChanged方法,此方法是从NotificationObject类继承过来的。Tags属性可以包含一个或多个标签,当Tags属性包含多个标签时,标签与标签之间将会使用逗号(,)进行分隔。
数据存储方面,我们将会直接使用上节课重构的结果——JsonDataStore类。打开App.xaml.cs文件,在App类里创建一个NoteStore静态属性:
代码 3
接下来,是时候考虑一下用户体验了。
设计用户体验
我们知道,每门课都有自己的笔记,就像每门课都有自己的作业一样,所以这里将会仿效作业本的做法,利用Pivot控件的特点,让每个Pivot项显示一门课程的笔记。现在,切换到Expression Blend,创建一个Windows Phone Pivot Page,并把它命名为NoteBookPage.xaml,完了之后把Pivot控件的Title属性设为"笔记本",把两个Pivot项的Header属性分别设为"销售心理学"和"行为金融学":
图 1
需要说明的是,Application Bar上的按钮没有设置图标,这是因为我没有找到合适的,下次有时间我自己设计一个放上去。那么,标题下面这么大的一块空位应该怎么安排呢?
首先,有两点我们是明确的,第一,我们需要列出笔记,第二,我们需要提供某种方式让用户切换标签。根据以往的经验,ListBox最适合用来列出笔记,至于切换标签的方式,我觉得SL for WP Toolkit的ListPicker也是一个不错的选择。此时,我的脑子里浮现出第一个设计:
图 2
然而,很遗憾,我对这个设计并未感到满意。第一,在手机屏幕这样的有限空间里,我们应该始终坚持把尽可能多的空间留给最重要的内容,ListPicker作为一个辅助元素应该只在用户需要切换标签的时候才显示,否则把空间腾出来,这样可以显示更多笔记。第二,笔记本和作业本、课程表有着类似的布局设计,但ListPicker的存在破坏了布局设计的一致性,这点我实在无法容忍。那么,怎样才能让ListPicker"呼之则来挥之则去"?
我们可以调整ListBox的大小,使之充满整个Pivot项,并把ListPicker藏在屏幕下方外面,然后在Application Bar上放置一个按钮,当用户单击这个按钮时,ListPicker将会从屏幕下方外面向上平移,直至屏幕底部:
图 3
这样,用户就可以通过ListPicker切换标签了。此时,我的脑子里冒出一个问题,为什么不直接以这种方式显示标签列表,而是大费周章地通过ListPicker打开一个新的页面呢?显然,我找不到合适的理由说服自己把ListPicker留下,同时,这个问题也给我带来了新的灵感。我们可以让显示标签的ListBox取代ListPicker,当用户单击Application Bar上的按钮时,ListBox将会从屏幕下方外面向上平移,直至覆盖显示笔记的ListBox为止,当用户选好标签之后,显示标签的ListBox将会向下平移,直至屏幕下方外面为止。
现在,把ListPicker删除,然后把一个ListBox添加到LayoutRoot,并调整它的大小,使之充满整个屏幕(覆盖后面的Pivot控件)。此时,Objects and Timeline面板上面的对象应该是这样的:
图 4
接着,把它向下平移,直至它的顶部和Pivot控件的底部重叠为止:
图 5
此时,它的Height、VerticalAlignment和Margin三个属性都会自动做出相应的调整:
图 6
接下来,我们将会为它创建动画。
单击Objects and Timeline面板上的+按钮:
图 7
在弹出的Create Storyboard Resource对话框里输入一个名称,然后按OK关闭对话框:
图 8
此时,Objects and Timeline面板将会变成这样:
图 9
确保ListBox处于选中状态,把播放指针拖到0.5秒的为止,然后把ListBox向上平移,直至和Pivot控件完全重叠为止。此时,Objects and Timeline面板将会变成这样:
图 10
看到这里,你可能会问,为什么只有结束时间的关键帧?你也可以手动设置开始时间的关键帧,如果没有设置,那么对象原本的状态将会默认为开始时间的关键帧。
当用户选好标签之后,我们需要把ListBox隐藏起来,不难想象,隐藏动画其实是显示动画的反转,那么,如何创建反转动画呢?非常简单,单击+按钮旁边的箭头,然后选择Duplicate:
图 11
此时,Expression Blend会为你创建一个ShowTagsStoryboard_Copy1动画。单击+按钮旁边的箭头,然后选择Rename把它重命名为HideTagsStoryboard。接着,再次单击+按钮旁边的箭头,然后选择Reverse反转动画。好了之后单击+按钮旁边的X按钮关闭Storyboard。
那么,我们如何触发这些动画?前面说过,当用户单击Application Bar上的按钮时,ListBox将会显示,这个比较简单,只需在按钮的事件处理程序里调用ShowTagsStoryboard的Begin方法就可以了:
代码 4
而当用户选好标签之后,ListBox将会隐藏,这个可以通过Expression Blend提供的ControlStoryboardAction来实现。打开Assets面板,选择Behaviors,然后把ControlStoryboardAction拖到Objects and Timeline面板的ListBox上:
图 12
此时,Objects and Timeline面板将会变成这样:
图 13
确保ControlStoryboardAction处于选中状态,在Properties面板上把EventName和Storyboard两个属性的值分别改为MouseLeftButtonUp和HideTagsStoryboard:
图 14
这样,当用户在ListBox里选好标签并松开手时就会触发HideTagsStoryboard。看到这里,你可能会问,为什么前面不直接在Application Bar的按钮上使用ControlStoryboardAction?这是因为Application Bar并非Silverlight的一部分,你不可以把它和我们平时接触到的Silverlight对象等同起来。事实上,如果你试图把ControlStoryboardAction拖到ApplicationBarIconButton上,Expression Blend会提示你"Not a valid drop target":
图 15
接下来,我们将会为两个ListBox定制数据模板。
首先,通过Data面板导入以下两个XML文件:
代码 5
代码 6
此时,Data面板将会变成这样:
图 16
把Data面板上的NoteCollection拖到显示笔记的ListBox上,然后进入列表项模板的编辑状态,把StackPanel的Margin属性、TextBlock的FontSize属性和TextBlock的TextWrapping属性分别设为PhoneTouchTargetOverhang、PhoneFontSizeNormal和Wrap:
图 17
好了之后退出列表项模板的编辑状态。接着,把TagCollection拖到显示标签的ListBox上,然后进入列表项模板的编辑状态,把StackPanel的Margin属性、TextBlock的FontSize属性和TextBlock的TextWrapping属性分别设为PhoneTouchTargetOverhang、PhoneFontSizeLarge和Wrap:
图 18
接下来干嘛?你懂的!
打开MainPage.xaml,添加一个菜单项,并让它导航至NoteBookPage页:
图 19
好了,按F5吧:
图 20
单击笔记本:
图 21
单击Application Bar上的按钮:
图 22
啊!忘记把ListBox的Background设成不透明了!
退出应用程序。把ListBox的Background改为PhoneBackgroundBrush。而动画方面,0.5秒感觉有点长,我们可以把它改为0.25秒:
代码 7
此外,还有一个地方值得改善的,我希望ListBox出来的时候能够有一种逐渐慢下来的感觉,而离开的时候则相反,逐渐快起来。这可以通过缓动函数(easing function)来实现。单击Objects and Timeline面板上的Open a Storyboard按钮,然后选择ShowTagsStoryboard:
图 23
展开ListBox节点,确保下面的RenderTransform处于选中状态:
图 24
然后在Properties面板上把EasingFunction设为Cubic Out:
图 25
好了之后仿照上述步骤把HideTagsStoryboard的EasingFunction设为Cubic In。现在,你可以按F5看看修改后的效果了。
接下来,是时候考虑一下笔记本的相关操作了。我们知道,课程表通常都是整个创建的,而作业通常也一次过把一门课当天要做的都记下来,对于这种集中式批量操作来说,提供保存所有更改和撤销所有更改是有必要的,但笔记本就不同了,里面的内容很可能分散在不同的时间点记录,没有太明显的集中式批量操作,如果我们遵循课程表和作业本的套路,要么用户不得不在每次记笔记时都额外执行一次保存操作,要么用户不得不承担最后可能忘记统一保存而丢失数据的风险。因此,我打算把五项操作简化为新建、编辑和删除三项,并在用户执行每项操作之后自动保存数据,其中,新建将会以ApplicationBarIconButton的方式放在Application Bar上:
图 26
而编辑和删除则放在上下文菜单里:
代码 8
根据之前的经验,我们需要一个新的页面来处理新建和编辑操作:
图 27
那么,我们应该如何设计这个页面呢?
想想看,Note类的哪些属性和用户无关?Id。哪些属性无需劳烦用户处理?CourseName。那么剩下的Content和Tags两个属性就应该出现在这里了。毫无疑问,TextBox完全能够胜任显示和编辑这两个属性的工作:
图 28
值得注意的是,这里不再通过普通的Button控件来提供"确定"和"取消"两项功能,而是通过Application Bar上的按钮来提供,为什么呢?当用户输入完毕之后,软键盘可能处于开启状态,它会遮盖普通的Button控件,这意味着用户就不得不先单击一下页面上的空白处关闭软键盘,再单击Button控件关闭页面,而Application Bar不会被软键盘遮盖,这意味着用户可以在输入完毕之后直接单击上面的按钮关闭页面。你知道吗,这个小小的简化可以极大地提升用户体验,之前测试课程表和作业本的时候,这个不必要的步骤曾多次让我误触TimePicker/DatePicker控件的有效范围,导致新的页面被打开,我对此深感厌恶,你知道,当用户对软件的操作感到厌恶时,后果将会很严重!
现在,回到NoteBookPage页,为Application Bar上的新建按钮添加一个事件处理程序:
代码 9
然后按F5:
图 29
从上图可以看出,软键盘和Application Bar是并列一起的,所以我可以在任何时候单击Application Bar上的按钮。此外,当我在TextBox里输入较长的内容时,TextBox还会自动调整自身的高度,以便完整显示我输入的内容。当我单击第二个TextBox进行输入时,整个页面将会稍稍向上平移,这样做的好处非常明显——避免软键盘遮盖TextBox!从这里不难看出,WP7在用户体验上的设计确实很体贴!
连接前端和后端
接下来,我们要为这些用户界面创建对应的ViewModel类。我们知道,整个笔记本就是一个Pivot控件,而每门课程的笔记则对应一个Pivot项,这个结构和上节课的作业本类似,于是,我们可以仿效上节课的做法,分别创建NoteBookViewModel和NoteListViewModel两个类:
代码 10
代码 11
由于每个Pivot项都包含了一个标题和一组笔记,NoteListViewModel类自然需要提供两个对应的属性:
代码 12
值得注意的是,这里不直接使用ObservableCollection集合,而是和第一节课的课程表一样,通过CollectionViewSource来提供集合视图,这样做的好处是我们只需指定过滤条件,剩下的事情CollectionViewSource会代为处理,而无需我们亲自出手:
代码 13
而NoteBookViewModel类则和上节课的作业本一样,直接使用ObservableCollection集合:
代码 14
并根据课程表里的数据创建对应的NoteListViewModel对象:
代码 15
有了ViewModel类,我们就可以着手处理数据绑定了。
现在,打开NoteBookPage.xaml文件,切换到XAML模式,在页面的资源字典里添加两个数据模板:
代码 16
接着,把它们应用到Pivot控件上:
代码 17
把LayoutRoot的DataContext属性去掉,然后在代码隐藏文件的构造函数里设置DataContext属性:
代码 18
好了,按F5吧。在打开笔记本之前,我们得先新建一些课程,否则Pivot控件不会创建任何Pivot项的:
图 30
好了之后就可以打开笔记本了:
图 31
当然,现在的笔记本既没有笔记也不能创建笔记,因为这部分功能还没实现呢!
新建和编辑笔记的工作是由NewOrEditNotePage页负责的,根据上两节课的经验,我们需要创建NewOrEditNoteViewModel、NewNoteViewModel和EditNoteViewModel三个类,但我已经厌倦了每次都要手工创建这么多类,而且还有这么多重复的代码,所以这次我要对这部分进行重构。在ViewModels文件夹里创建一个NewOrEditItemViewModel泛型类,并让它继承NotificationObject类:
代码 19
仔细观察NewOrEditCourseViewModel和NewOrEditAssignmentViewModel两个抽象类,不难发现,它们的有效成分是页面标题、Model类的实例和提交数据的方法。页面标题很好处理,一个普通的类型为string的Title属性:
代码 20
Model类的实例有点棘手,因为我们有3个不同的Model类,怎么处理?我们有两个选择,一个是把属性的类型声明为object,另一个就是这里采用的做法——泛型:
代码 21
这也正是我把NewOrEditItemViewModel类声明为泛型类的缘由。我们知道,每个Model类的数据都会提交到不同的地方,我们显然不能把这部分代码固化在NewOrEditItemViewModel类里,所以我决定通过委托把代码外包出去:
代码 22
最后,我们需要在构造函数里初始化这三个成分:
代码 23
那么,我们如何使用这个类呢?
打开NewOrEditNotePage.xaml.cs文件,重写OnNavigatedTo方法:
代码 24
这个是我们根据查询字符串初始化DataContext属性的基本套路。当action的值是new时,初始化DataContext属性的代码应该是这样的:
代码 25
而当action的值是edit时,初始化DataContext属性的代码应该是这样的:
代码 26
这样,下次我们再有新的Model类就可以直接创建ViewModel类的实例了。
看到这里,你可能会问,代码24那个套路每次都是一样的,应该可以处理一下吧?嗯,可以的。我们有两种处理方案,第一种方案是创建一个NewOrEditItemViewModelFactoryBase抽象类,并在里面使用模板方法模式(Template Method Pattern)处理那个套路,这样做的代价是我们需要为每个Model类创建一个对应的工厂类。如果你不喜欢这种做法,你可以选择第二种方案,创建一个NewOrEditItemPage类,按照代码24重写OnNavigatedTo方法,然后应用模板方法模式,这样做的代价是我们需要让每个新建/编辑页面继承这个类。无论我们选择哪种方案,有一点是可以肯定的,那就是NewOrEditItemViewModel类的三个成分似乎无法避免,因为这些工作始终要做,而我们从一种方案改成另一种方案只不过是把这些工作从一个地方挪到另一个地方罢了。如果你确实希望减轻这些工作,(理论上)也不是不可能,不过你得做好心理准备,因为你需要创建一个足够灵活的子系统,并且提供充足的元数据,这些数据包括每个Model类在不同状态下分别对应的页面标题、新建Model类的实例需要初始化哪些属性以及这些属性的数据来自哪里或者如何计算、克隆现有Model类的实例的方法是哪个、数据提交到哪里以及调用哪个方法、提交数据的是否需要同时保存到独立存储区等等等等。当我写到这里的时候,我已经隐约感觉得出这将是个很复杂的子系统,如果你真的打算实现这个子系统,那么请你先把右手抬起来,捂在左边胸口,然后问问自己:
- 会有人愿意负责提供这些数据吗?如果有,会是谁呢?
- 会有人愿意负责维护这个子系统吗?如果有,会是谁呢?
- 当你的程序用上这个子系统之后,你能得到什么实质的好处?
- 这些好处能否抵消提供数据和维护子系统的付出?
如果你没有自欺,你的心将会告诉你这个决定是否值得。
创建好ViewModel类之后,我们就可以着手处理它和页面之间的关联了。首先是设置数据绑定,需要设置的控件以及对应的绑定表达式如下表所示:
描述
类型
属性
绑定表达式
页面标题
TextBlock
Text
{Binding Title}
笔记内容
TextBox
Text
{Binding Item.Content, Mode=TwoWay}
笔记标签
TextBox
Text
{Binding Item.Tags, Mode=TwoWay}
表 2
接着,为Application Bar上的两个按钮创建事件处理程序:
代码 27
页面的功能有了,可打开页面的功能还没好呢!
我们知道,NewOrEditNotePage页的入口点有两个,而且都在NoteBookPage页上,一个是Application Bar上的新建按钮,另一个是上下文菜单里的编辑菜单项。当用户单击新建按钮时,我们需要告诉NewOrEditNotePage页当前的课程是什么,但是,我们从哪里获取这个信息?办法有很多种,其中一种是在NoteBookViewModel类里创建一个SelectedNoteList属性:
代码 28
并把它绑到Pivot控件的SelectedItem属性:
代码 29
然后在新建按钮的事件处理程序里通过SelectedNoteList的Header属性获取课程名称:
代码 30
当用户单击编辑菜单项时,我们需要告诉NewOrEditNotePage页笔记的Id是什么。我们知道,上下文菜单是嵌在列表项里的,这意味着它能从列表项那里继承DataContext属性的值,而这个值正是当前选中的笔记,所以我们可以通过菜单项的DataContext属性获取笔记的Id:
代码 31
类似地,删除操作也是通过相同的方式获取当前选中的笔记,然后把它删除:
代码 32
好了,不知不觉又到看效果的时候了!打开笔记本:
图 32
单击新建按钮:
图 33
输入笔记内容和笔记标签,然后单击确定返回:
图 34
接着,长按笔记打开上下文菜单:
图 35
选择编辑:
图 36
嗯?我刚才没有输入标签?还是我现在眼花看错?为什么标签没有保存?现在,重新输入标签,单击页面上的空白处,单击确定返回,然后再次编辑笔记:
图 37
哈!这次有了!怎么回事?!
这个时候,我的脑子里突然蹦出一个问题,当TextBox处于编辑状态时,单击确定按钮会不会触发TextBox的LostFocus事件?为了回答这个问题,我做了一个试验,结果发现,当TextBox处于编辑状态时,单击页面上的按钮或者空白处都能触发TextBox的LostFocus事件,而单击Application Bar上的按钮却不会,在这种情况下,TextBox的内容不会提交!这个问题不难解决,我们只需在调用Submit方法之前让TextBox失去焦点就行了,要实现这个效果,最简单的办法是让页面获得焦点:
代码 33
这等效于单击页面上的空白处。不幸的是,这个办法只在新建笔记时有效,我不知道为什么,看来我们只能走别的路子了。在Silverlight里,TextBox的Text属性在两种情况下会更新绑定源,第一种情况我们刚才试过了,结果你也知道了,第二种是手动更新,这里的手动更新并不是指把数据从Text属性手动复制到Note对象的对应属性,而是告诉Text属性的绑定表达式把当前数据更新回绑定源。我们知道,当用户单击Application Bar上的确定按钮时,最多只有一个TextBox获得焦点,其它TextBox会因为失去焦点自动更新,因而没有必要对每个TextBox进行手动更新。那么,如何才能得到获得焦点的控件?FocusManager类提供了一个GetFocusedElement静态方法,它返回获得焦点的控件,如果这个控件是TextBox,我们就告诉Text属性的绑定表达式把当前数据更新回绑定源:
代码 34
重新运行应用程序,这次没问题了。虽然这个问题我们自己也可以解决,但我个人认为这是系统应该考虑到的问题,即使普通按钮和Application Bar上的按钮有着本质的区别,容许这种行为上的不一致会为开发者带来不便和困惑。
标签
到目前为止,我们的笔记本只不过是一个很普通的笔记本,虽然我们提供了与标签相关的用户界面,但这部分功能还没真正实现出来。那么,如何实现这部分功能?
我们知道,当用户单击Application Bar上的显示标签按钮时,显示标签的ListBox将会滑出来,里面列出当前课程的标签,用户可以从中选择一个标签,此时,ListBox将会滑出去,笔记本的内容也将根据选中的标签进行筛选。从这里不难看出,我们需要在NoteListViewModel类里添加两个属性,一个用于存放当前课程的标签,另一个用于存放当前选中的标签:
代码 35
需要说明的是,在使用Tags属性之前,我们需要在构造函数里初始化它,即创建一个空的集合对象。接着,把它们分别绑到ListBox的ItemsSource和SelectedItem两个属性:
代码 36
那么,当用户选好标签之后,我们如何筛选笔记?还记得我们是如何根据课程名称筛选笔记的吗?我们直接把课程筛选条件告诉CollectionViewSource,当数据源发生改变时,CollectionViewSource将会自动筛选,因此,我们不妨考虑把标签筛选条件整合进去,让CollectionViewSource一并处理:
代码 37
需要说明的是,当用户还没选择任何标签时,SelectedTag属性的值为null,此时,我们应该按照不做标签筛选的情况处理,否则看看笔记的标签是否包含用户选中的标签。看到这里,你可能会问,当用户选择一个标签时,数据源并未发生任何改变啊,CollectionViewSource应该不会自动筛选吧?这个问题问得好!事实上,它不会自动筛选,我们需要手动刷新一下它生成的视图:
代码 38
那么,如何初始化标签列表?
想想看,什么时候需要初始化标签列表?每次打开笔记本的时候肯定需要初始化标签列表,但除此之外呢?当用户新建或编辑笔记时,可能引入新的标签;当用户编辑或删除笔记时,可能删除现有标签,这些都会导致重新计算标签列表。既然计算标签列表的代码需要在这么多地方使用,我们当然应该把它提取到一个单独的方法里:
代码 39
计算标签列表的思路非常简单,首先,选取当前课程的笔记(忽略没有标签的),接着,从中提取标签,为了避免前/后空格的影响,这里使用Trim方法做了处理,然后,去掉空字符串以及重复的标签,最后,把标签添加到Tags属性。
现在,请思考一下,我们应该在哪调用ComputeTags方法?有些同学可能会说,这还不简单,分别在NoteListViewModel类的构造函数和三个操作的事件处理程序里调用不就行了?在NoteListViewModel类的构造函数和删除操作的事件处理程序里调用是没问题的,但在新建和编辑两个操作的事件处理程序里调用就有问题了,为什么呢?举个例子吧:
代码 40
你觉得上面代码的最后一行是在NewOrEditNotePage页显示之前还是之后执行呢?答案是之前,这意味着标签列表的计算在用户编辑笔记之前就完成了,这显然不是我们期望的效果。怎么办?
想想看,每次从NewOrEditNotePage页返回都会发生什么事呢?触发NoteBookPage页的Loaded事件!于是,我们可以把代码39里的最后一行放在Loaded事件处理程序里,不过,这样做会导致一个问题,每次从主菜单打开NoteBookPage页时,当前课程的标签列表会被计算两次,第一次是在NoteListViewModel类的构造函数里,第二次是在Loaded事件处理程序里,因为NoteBookPage页每次显示都会触发Loaded事件。怎么处理这个问题?最简单的办法是通过一个bool变量区分NoteBookPage页是否已经打开过。不过,既然我们的目的只是为了在标签发生改变时做些事情,为什么不直接监听Note对象的PropertyChanged事件呢?要实现这个效果,有三个事儿需要我们做的:
- 监听现有Note对象的PropertyChanged事件。
- 监听App.NoteStore.Items的CollectionChanged事件,一旦有新的Note对象添加进来就监听它的PropertyChanged事件。
- 监听App.NoteStore.Items的CollectionChanged事件,一旦现有的Note对象被删除就移除PropertyChanged事件的监听。
我们可以在PropertyChanged事件处理程序里调用ComputeTags方法。
虽然这种做法听起来有点复杂,但它避免了第一种做法的问题。本质上,这两种做法是等效的,只是一个在前台处理,另一个在后台处理,而正是这个角度的转变让我们对它们有了更进一步的了解。想想看,用户并非每次新建/编辑笔记之后都会单击Application Bar上的显示标签按钮,一个比较常见的使用情景用户把当天的笔记都输入了,然后通过标签的筛选来复习特定的内容,这样的话,在用户每次新建/编辑笔记之后重新计算标签列表显然没有必要。事实上,如果用户没有单击Application Bar上的显示标签按钮,我们根本没有必要计算标签列表,换句话说,我们可以把代码39里的最后一行放在显示标签按钮的Click事件处理程序里,从而实现按需计算:
代码 41
需要注意的是,当用户单击Application Bar上的显示标签按钮时,如果用户还没创建任何课程,直接调用ComputeTags方法将会引发异常,所以我们需要在调用之前判断一下。不过,这种做法也有个问题,试想一下,如果用户多次单击Application Bar上的显示标签按钮,其间没有新建/编辑任何笔记,那么,除了第一次计算标签内容是必要的,后面几次都是多余的。那么,如何才能避免多余的计算?看到这里,你可能会说,为什么不把后两种做法结合起来试一下呢,比如说,我们可以通过一个bool变量标识是否需要计算,然后在PropertyChanged事件处理程序里把它的值设为true,而在ComputeTags方法里,仅当这个变量的值为true时才执行计算,执行完毕之后把它的值设为false。嗯,这个主意不错,我就把它留给你当课后作业吧。
好了,不知不觉又到看效果的时候了!按F5运行应用程序,新建一条笔记:
图 38
单击Application Bar上的显示标签按钮:
图 39
单击页面空白处收回标签列表。再新建一条笔记:
图 40
这次,我们给它两个标签,其中一个标签是现有的,另一个是新的,并且分隔符后面有个空格。单击确定返回,然后单击Application Bar上的显示标签按钮:
图 41
再新建一条笔记:
图 42
现在,单击Application Bar上的显示标签按钮:
图 43
选择buying behavior:
图 44
再次单击Application Bar上的显示标签按钮,选择sales strategy:
图 45
非常好!不过,现在有个问题,我想显示所有笔记怎么办?
没问题,我们可以在计算标签列表的时候加插一个"特殊"的标签:
代码 42
并在筛选的时候进行"特殊"处理:
代码 43
好了,重新运行应用程序,分别为两个课程新建一些笔记:
图 46
图 47
现在,单击Application Bar上的显示标签按钮:
图 48
选择disposition effect:
图 49
现在,切换到sales psychology课程,单击Application Bar上的显示标签按钮,选择sales strategy:
图 50
再次单击Application Bar上的显示标签按钮,选择(全部):
图 51
现在,切换回behavioral finance课程:
图 52
怎么回事?!我们刚才已经做了筛选啊!
原来,当我们切换课程时,ListBox的ItemsSource属性发生改变,导致ListBox的SelectedItem属性被重设为null,而NoteListViewModel对象的SelectedTag属性和ListBox的SelectedItem属性是双向绑定的,因而也被重设为null了。ListBox的SelectedItem属性被重设为null是对的,因为新的数据源不一定包含SelectedItems属性的值,但把NoteListViewModel对象的SelectedTag属性也重设为null就不对了,因为同一个NotelistViewModel对象的Tags属性肯定包含SelectedTags属性的值,因此,SelectedTag属性的set访问器应该忽略这个重设:
代码 44
重新运行应用程序,重新执行一次上面的测试,嗯,这次没问题了。
命令与行为
我们知道,WPF和最新的Silverlight 4都支持命令绑定,比如说,Button控件有一个Command属性和一个CommandParameter属性,前者用于绑定实现ICommand接口的对象,后者用于绑定传给前者的参数,但SL for WP却只有一个ICommand接口,这意味着我们无法为按钮设置命令,而Application Bar上的按钮这种异类就更不用说了。幸亏Prism为我们带来了ApplicationBarButtonCommand(使用之前请先引用Microsoft.Practices.Prism.Interactivity.dll类库),它能让我们为Application Bar上的按钮设置命令。下面,我们拿NewOrEditNotePage页来示范它的用法。
在设置命令之前,我们得先有个命令,而命令通常是由ViewModel类提供的。打开NewOrEditItemViewModel.cs,在NewOrEditItemViewModel类里添加一个SubmitCommand属性:
代码 45
那么,我们应该如何初始化它?一般的做法是创建一个SubmitCommand类,并让它实现ICommand接口,然后在NewOrEditItemViewModel类的构造函数里把SubmitCommand类的实例赋给SubmitCommand属性。如果你不嫌麻烦的话,你可以这样做,不过,由于创建命令对象的需求非常普遍,Prism为我们带来了DelegateCommand泛型类,我们只需把提交数据的代码传给它的构造函数就可以了:
代码 46
接着,把_submit私有字段以及在构造函数里初始化它的代码删除,因为我们不再需要它了。删除之后,把Submit方法改成这样:
代码 47
换句话说,原来的_submit私有字段被现在的SubmitCommand属性取代了。
现在,打开NewOrEditNotePage页,从Assets面板上把ApplicationBarButtonCommand拖到Objects and Timeline面板的PhoneApplicationPage上:
图 53
此时,Objects and Timeline面板将会变成这样:
图 54
接着,在Properties面板上把ButtonText属性的值设为"确定":
图 55
单击CommandBinding右边的小正方形,并选择Custom Expression:
图 56
在弹出的Custom expression对话框里输入"{Binding SubmitCommand}"并按回车:
图 57
用同样的办法把CommandParameterBinding设为"{Binding Item}"。
那么,用户提交数据之后如何返回?这个时候就轮到ApplicationBarButtonNavigation上场了。从Assets面板上把ApplicationBarButtonCommand拖到Objects and Timeline面板的PhoneApplicationPage上,此时,Objects and Timeline面板将会变成这样:
图 58
接着,在Properties面板上把ButtonText和NavigateTo两个属性的值分别设为"确定"和"#GoBack":
图 59
需要说明的是,"#GoBack"是一个硬性规定的特殊值,当我们把NavigateTo属性的值设为"#GoBack"时,ApplicationBarButtonNavigation会调用NavigationService.GoBack方法返回,而当我们把NavigateTo属性设为XXX.xaml时,它会调用NavigationService.Navigate方法导航至对应的页面。
那么,Text属性更新绑定源的问题呢?难道Prism也提供了相应的组件?没有,这次我们得亲自出手了。右击Utils文件夹,然后选择Add New Items,在弹出的New Item对话框里选择Behavior,并把它命名为AppBarButtonUpdateSource:
图 60
我们知道,Application Bar上的按钮并非Silverlight的一部分,因此Behavior无法直接作用于它,而Silverlight里只有PhoneApplicationPage类提供了访问Application Bar的方法,因此我们需要把AppBarButtonUpdateSource的目标类型改为PhoneApplicationPage:
代码 48
这也正是我们把ApplicationBarButtonCommand和ApplicationBarButtonNavigation拖到PhoneApplicationPage上的原因。那么,如何才能找到Application Bar上的按钮?Prism为我们带来了FindButton扩展方法,可以通过按钮的文字来查找,因此,我们需要创建一个ButtonText属性和一个_button私有字段,前者用于指定待查找按钮的文字,后者用于保存找到的按钮:
代码 49
FindButton扩展方法需要一个实现IApplicationBar接口的对象,而能够提供这个对象的只有PhoneApplicationPage对象,后者可以通过Behavior的AssociatedObject属性访问。于是,我们可以在OnAttached方法里初始化_button私有字段:
代码 50
找到按钮之后,我们需要把更新绑定源的代码关联到按钮上,而做到这点的唯一办法就是为按钮创建一个Click事件处理程序:
代码 51
最后,在OnDetaching方法里解除它们之间的关联:
代码 52
现在,重新编译项目,然后从Assets面板上把AppBarButtonUpdateSource拖到Objects and Timeline面板的PhoneApplicationPage上,并把ButtonText属性的值设为"确定"。此时,Objects and Timeline面板将会变成这样:
图 61
不过,这个顺序是不对的,AppBarButtonUpdateSource应该排在其它两个的前面,但这个顺序在Expression Blend里无法调整,因此我们需要手动修改XAML。
至于取消按钮,由于它只是简单地返回,我们只需为它添置一个ApplicationBarButtonNavigation就行了。添置好后,Objects and Timeline面板将会变成这样:
图 62
现在,我们可以把这两个按钮的Click事件处理程序去掉了。
命令绑定是MVVM模式的重要组成部分,它不但可以进一步降低View和ViewModel之间的耦合度,还可以简化单元测试的工作。目前我们通过Behavior来实现命令绑定只是权宜之计,希望微软可以在将来的版本里把这部分功能补完了。还有的就是希望微软能够进一步完善Application Bar,包括处理焦点问题以及提供更丰富的访问方式。
下课了……