WM有约(三):下一次是什么时候?
Written by Allen Lee
不要留恋过去
怎样才能约束用户,不让其选择过去的日期呢?有一个很傻的办法,就是每次启动应用程序的时候,自动把MonthCalendar控件的MinDate属性的值设为今天。这样虽然禁止了用户选择过去的日期,却带来另外一些问题:
有鉴于此,我们采用另一种办法,就是在用户选中某个日期时,判断这个日期是否已经过去,若是,则禁用Pin菜单项,若否,则启用Pin菜单项。那么,如何获知用户选中了某个日期?最简单的办法就是使用MonthCalendar控件的DateChanged事件:
代码 1
运行应用程序,你会发现,当我选中今天或者将来的日期时,Pin菜单项是启用的(图1),而当我选中过去的日期时,Pin菜单项则是禁用的(图2):
图 1
图 2
这(几)天不要选
在继续之前,我们有必要搞清楚,"排除某(几)天"究竟是什么意思。在这里,"排除某(几)天"并不是指禁止用户选中那(几)天,而是指那(几)天不在计划中,但我们很清楚,计划赶不上变化,或许那(几)天真正到来的时候又可以选了。
和之前的"钉住日期"相比,"排除日期"除了无需在MonthCalendar控件上有所反映之外,其它部分基本上是一样的,它支持排除某天、连续的几天和某个周末,用来保存被排除的日期的文件和应用程序放在同一个文件夹里,应用程序在启动的时候会检查这个文件是否存在,如果不存在就创建一个空白的文件。从上面这些描述来看,"排除日期"和"钉住日期"在很大程度上共享着相同的代码,于是,接下来就是考虑如何重用现有的代码并实现新的功能。
首先要处理的是LoadPinnedDates和SavePinnedDates两个方法(参见《WM有约(一):你好,CF》的代码5和代码6),我们提取这两个方法的代码,并创建两个新的方法:
代码 2
代码 3
这样,LoadPinnedDates和SavePinnedDates两个方法就可以简化为分别对LoadDates和SaveDates两个方法的调用了,而LoadExcludedDates和SaveExcludedDates两个方法也可以如法炮制了。在着手实现这些方法之前,我们还需要提供一个东西,那就是文件的路径,也是我们接下来需要做的事情——改造GetFilePath方法(参见《WM有约(一):你好,CF》的代码4),改造后的GetFilePath方法将会用来映射文件路径:
代码 4
有了这些准备,我们就可以着手实现LoadExcludedDates和SaveExcludedDates两个方法了:
代码 5
至于LoadPinnedDates和SavePinnedDates两个方法的新版本就留给读者自行处理了。
接着就是"排除日期"的核心功能——ExcludeWeekend和ExcludeRange两个方法了,它们与PinWeekend和PinRange两个方法(参见《WM有约(一):你好,CF》的代码1和代码2)的最大区别就是不需要把操作结果反映在MonthCalendar控件上,而它们的共同之处是都要计算具体的日期并把它们添加到对应的集合里。我们先来看看计算具体的日期这部分功能,它分为两种情况,一种是计算周末的,另一种是计算两个日期之间的,如果这两个日期相同,则视为一天,于是,我们可以创建CalculateWeekend和CalculateRange两个方法来分别负责这两种情况:
代码 6
有了这些准备,我们就可以着手实现ExcludeWeekend和ExcludeRange两个方法了:
代码 7
至于PinWeekend和PinRange两个方法的新版本就留给读者自行处理了。
还差什么呢?对,用户界面,没有这个,我们辛苦了这么久就白干了:
图 3
还有Exclude菜单项的相关代码:
代码 8
噢,别忘了在InitializeFile方法(参见《WM有约(一):你好,CF》的代码12)里添加检查文件是否存在的代码,以及在适当的地方添加保存数据的代码,否则……
运行应用程序,选中2009年1月17日到2009年1月31日之间的日期,然后单击Exclude菜单项:
图 4
通过资源管理器找到ExcludedDates.txt文件,然后用Word Mobile查看里面的内容,结果发现只有下面3天!
图 5
问题出在哪里?原来,我选中的那几天的开始日期恰好是星期六,于是应用程序"自作聪明"地把它视为一般周末!如何解决这个问题?回到代码8,我们知道,ExcludeWeekend方法的调用需要满足两个条件,第一个是用户只选中了一天,另一个则是这天是星期六。要知道用户是否只选中了一天,我们只需要看看SelectionStart和SelectionEnd两个属性是否同一天:
代码 9
再次运行应用程序,这次就正常了:
图 6
需要提醒的是,Pin菜单项的相关代码由于应用了相同的逻辑,于是也存在相同的问题,不过解决方法是一样的,所以这里就不说了。另外,因为"排除日期"不像"钉住日期"那样会在用户界面上有所反映,所以当我们单击Exclude菜单项时,一切都在后台完成,如果用户不知情的话,感觉起来就像什么也没干一样,为了增强用户体验,最好就显示一个消息框告诉用户日期已被排除。
这(几)天应该选
由于"包含日期"和"排除日期"极其相似,再加上我们在实现"排除日期"时提取的公共代码也适用于"包含日期",于是,我们可以用非一般的速度来实现"包含日期"的内部逻辑:
代码 10
至于用户界面,我们同样为它添加一个Include菜单项:
图 7
而这个菜单项的相关代码如下:
代码 11
其它东西,例如应用程序启动的时候检查用来保存日期的文件是否存在、读取保存的日期和在适当的时候保存日期,和前面的实现大同小异,这里就不细说了。
运行应用程序,选中2009年2月14日,然后单击Include菜单项:
图 8
由于这天刚好是星期六,所以应用程序执行了包含周末的逻辑,这也是预期的行为:
图 9
到了这里,你可能会认为"排除日期"和"包含日期"也是时候告一段落了,但事实上我们还有一个问题需要处理。试想一下,如果我对同一个日期先后执行包含和排除操作,那么应用程序是否应该分别在m_IncludedDates和m_ExcludedDates两个集合里登记这个日期?我们知道,"排除日期"和"包含日期"都是用来反映计划的调整,比起分别在两个地方登记同一个日期,执行抵消操作或许更有意义。举个例子,刚才我包含了2009年2月14日,现在我要排除这个日期,那么应用程序应该从m_IncludedDates里删除这个日期而不是向m_ExcludedDates添加这个日期。怎么样?是不是很简单?然而,这个东西实现起来一点都不容易,因为我们通常操作的是一组日期而不是单个日期,如果我们足够好运,那么要抵消的日期集合会是被抵消的日期集合的子集,如果我们不够运气,那么……一般地,如果我们要包含一组日期,那么我们要先检查m_ExcludedDates是否包含了这些日期的部分或全部,如果是,则从m_ExcludedDates里删除相同部分,剩下的才添加到m_IncludedDates。以IncludeWeekend方法(参见代码10)为例,从最初的monthCalendar1.SelectionStart到最后的m_IncludedDates.AddRange需要经过如下四步:
图 10
其中,第三步的Subtract方法是解决这个问题的关键,那么,如何实现这个方法呢?我们知道,List<T>本身没有提供这个方法,要想达到这样的效果就得使用C# 3.0的扩展方法了。下面来看看我的实现:
代码 12
对于second里的每个日期,Subtract方法试图从first里删除,并通过Remove方法的返回值判断删除操作是否成功,如果不成功,就意味着这个日期应该添加到m_IncludedDates里,于是返回这个日期。有了这些准备,我们就可以着手修改IncludeWeekend方法:
代码 13
另外,IncludeRange、ExcludeWeekend和ExcludeRange等方法也需要修改,不过都是大同小异,所以就不一一列举了。
下一次是什么时候?
下一次……在MonthCalendar控件下面……
图 11
通常,这种可预测的"下一次"都意味着计算周期的存在,对于这个应用程序,这个周期是两周,以星期六为计算基准,比如说,假设上图的5、6和7三天已被钉住,那么 下次应该被钉住的日期将会是19、20和21三天,于是"Next time:"下面的Label就应该显示"2008年12月19日"。这个计算过程的一般形式如下图所示:
图 12
有了这些分析,我们就可以着手实现CalculateNextTime方法了:
代码 14
故事到此结束了吗?当然不是,前面我们花了这么多精力来实现"排除日期"和"包含日期",如果仅仅用来保存一些日期,那么我也未免太无聊了。
首先,我们来看看"包含日期"将会如何影响"下一次"的计算,还是借用图11,假设19、20和21三天已被钉住,今天是23号,那么"Next time:"下面的Label应该显示"2009年1月2日",但如果26、27和28三天已被包含,那么"Next time:"下面的Label就应该显示"2008年12月26日"了。简而言之,在时间轴上排在前面的"包含日期"将会取代使用默认算法计算出来的日期:
代码 15
接着,我们再来看看"排除日期"将会如何影响"下一次"的计算,假设2、3和4三天已被钉住,今天是6号,那么"Next time:"下面的Label应该显示"2009年1月17日",但如果17到31之间的日期已被排除,那么"Next time:"下面的Label就应该显示"2009年2月7日"了。
图 13
简而言之,"排除日期"会导致使用默认算法计算出来的日期逐周往后推,直到计算出来的日期没被排除为止:
代码 16
由此可见,完整的CalculateNextTime方法应该包含如下四步:
图 14
其中,第一步和第四步是从代码14里分解出来的:
代码 17
有了这些准备,我们就可以着手实现完整的CalculateNextTime方法了:
代码 18
最后,终于到最后了,我们要把计算结果显示在应用程序主窗体的"Next time:"下面,那么,我们应该在什么时候显示呢,又应该在什么时候更新呢?用Activated事件!你可能会问,为什么不用Load事件呢?这是因为当用户单击应用程序右上角的关闭按钮时,应用程序实际上只是最小化到后台,当用户通过菜单或者其它方式再次启动应用程序时,实际上只是把应用程序"还原"到前台,而在这个过程里Load事件并不会被触发。事不宜迟,让我们完成本集的最后一段代码吧:
代码 19
运行应用程序,终于看到下一次是什么时候了:
图 15
你还想要什么?
在这本集里,我们花费巨大精力实现"下一次"的计算,然而,"除了'现在',你永远不能生活在任何其他时刻,你所能得到的只是现在的时光,未来在到来时也只不过是另一个现在"([美]韦恩·W·戴尔,《你的误区》),好好把握每一个"现在",你将会得到一个满意的轨迹。
到目前为止,应用程序的用户界面都是为"垂直"屏幕设计的,有没有想过,假如用户旋转设备的屏幕,使之变成"水平"的,将会发生什么事情呢?下一集,我们将会探讨这个问题及其解决方案,我们还会尝试创建用户控件来封装MonthCalendar控件、"下一次"Label以及相关的代码,如果"时间"允许的话,我们还会看看如何在这个用户控件上实现数据绑定。