65.9K
CodeProject 正在变化。 阅读更多。
Home

一种快速灵活的日期输入的构想

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.62/5 (10投票s)

2017年6月8日

CPOL

19分钟阅读

viewsIcon

8716

downloadIcon

132

在我看来,日期选择器(DatePicker)只是对纯文本框日期输入方式存在许多问题的一种部分解决方案。您是否希望能够在一个接受日期的文本框中输入“66”,然后它将其解释为“6月6日”? 或者输入“FR”,并将其解释为上一个星期五?或者输入“thanks”然后......

引言

多年来,我一直在研究一种以最少击键次数输入日期的方法,并在我构建的一些应用程序中实现了一些想法。为什么您必须输入月份,而您只需要指定月份中的某一天?我使用这个概念来输入购买日期,而购买日期只发生在过去。因此,如果您输入了一个日期或月份和日期,我就会实现一个默认为过去日期而不是未来的日期。因此,即使是1月,当您想指定12月的一天时,为什么还要输入年份呢?为什么您不能指定一个节日,然后让日期自动添加?

我发现的另一个更容易输入日期的方式是输入星期几,因为我经常不知道具体日期,只知道是星期几,而用英语指定星期几只需要一两个字符。

随着我不断地创建一个更健壮、易于扩展且能够处理不同语言的设计,其他想法也陆续加入进来。

基本概念

数字输入

众所周知,用户在键盘上进行输入是最快捷的方式。我对输入日期不太满意,因为它通常需要用户输入比必要更多的击键次数。为什么不让用户只输入一个数字,然后根据这个数字确定日期呢?如果用户输入一个小于31的两位数,这(通常)可以被认为是月份中的某一天。实际上,我做得更进一步,创建了一个设计,它会假定该数字是当前日期之前的月份中的某一天。如果今天是16号,用户输入“20”,那么就假定输入的值是上个月的20号。也可以假定该日期是未来的日期,或者是最接近当前日期的日期。该设计还可以适应偏好未来的日期。

显然,大于31(或大于假设月份的天数)但不是太大的数字,可以被假定为月份-日期组合(或日期-月份组合)。例如,如果用户输入“91”,那么一个明显的假设是用户输入的是9月1日,因为不存在91号。这个日期也可以输入为“901”。如果用户输入“111”,这可能是1月11日或11月1日。一个安全的假设是1月1日,并且只在月份是两位数时才要求输入4位数字以强制输入月份。另一种可能性是假定最接近当前日期的日期。

年份的处理方式与月份有些类似。6位数字将是“mmddyy”格式,表示两位数的年份;8位数字将是“mmddyyyy”格式,表示四位数的年份。该设计实际上处理了更多的组合,因为首先会尝试最明显的解释,如果无效,则会尝试其他可能性,包括日期后跟月份的组合。

概念的一部分是能够在不加空格的情况下输入一个数字,然后根据该数字确定日期。显然,如果日期输入带有分隔的数字,工作会更容易,但输入可能会混淆预期的顺序;代码仍然会尝试创建一个有效的日期。如果输入的数字小于或等于月份的天数,那么代码会假定该数字代表日期。随着数字变大,涉及的决策就越多。例如,“66”很容易转换为6月6日,“812”转换为8月12日,但“111”可能是1月11日或11月1日。“0402”最好解释为4月2日,但“9121”呢?然后还有输入月份时拼写出月份名称或其缩写的情况。如果数字太大而无法作为月份中的日期,则需要查看该数字是否代表年份和日期。

字母

除了使用月份的数字外,还允许用户输入月份名称,包括只指定部分月份的缩写。后来的一个想法是允许使用数字输入月份,并且不加空格,这样“N1”将被解释为11月1日。这很有帮助,因为“111”可能是1月1日。当然,有些月份以相同的字母开头,所以需要多个字母来区分两个不同的月份,特别是字母“J”是英语中几个月份的开头字母。幸运的是,大多数可能与数字输入混淆的月份(即2月、3月、10月、11月、12月)在英语中都以独特的字母开头。唯一的问题是1月,对于1月,只输入字母“J”就可以表示1月,而“July”和“June”都需要至少3个字母。

利用这两个概念,大多数日期输入都可以通过不超过3个字符的输入完成。
另一个有用的输入选项是能够使用星期几或缩写的星期几。如果用户输入“fr”,代码将返回上一个星期五的日期(3天前)。我发现这非常有用,因为我经常知道感兴趣的日期是星期几,但不确定对应的具体日期。指定星期几只需要一两个字符。

然后,还扩展了设计,允许用户输入星期几,并提供一种方法来指定月份中的哪一天或过去/未来几周(使用带符号的数字)。然后还可以通过输入节日名称或缩写来指定节日。

设计

代码基本上有三个部分处理用户输入的各个解码方面,还有一个XML文件用于处理不同语言的名称(和节日)差异以及指定缩写。

顺序偏好

顺序偏好主要处理日期、月份和年份输入的默认顺序。世界各地默认顺序不同。在美国是月份-日期-年份,但世界上大多数地方是日期-月份-年份,在中国是年份-月份-日期(我认为这是最好的顺序)。为此,将有一个单独的类来处理每种顺序,因为涉及大量决策,特别是当输入是单个数字时。决策的一部分是,如果默认顺序无效,代码将尝试其他顺序。所有顺序偏好类都继承自抽象类abstractOrderPreference。目前,我只实现了OrderPreferenceMDY类,它尝试在默认的月份-日期-年份顺序中查找日期。通过指定正确的Order Preference类,可以适应日期输入的文化差异。

Order Preferences类中的大部分代码处理将日期输入为最多8位数字的字符串(或最多6位数字加上月份)。使用一个名为ParseDate的私有类可以简化代码,其构造函数接受两个字符串——一个指定另一个字符串中的位置是属于月份、日期还是年份。
初始化Order Preference类后,必须设置两个属性:RelativeDate和CreateDate委托。

对于“111”这样的输入,AbstractOrderPreference类需要RelativeDate来区分两种选择(1月11日或11月1日)。提供月份可以帮助选择最接近的日期。

Order Preference计算的一个重要方面是CreateDateDelegate。此委托具有月份、日期和年份的参数,并负责根据偏好调整日期,以便在日期输入不完整时(例如缺少月份和年份或仅缺少年份)进行处理。

日期相对

当信息缺失(如缺少月份和年份或仅缺少年份)时,日期的完成通过继承自抽象类abstractDateResolver的类来解决。通过选择此抽象类的派生类来指定解析方法。目前只有一个类被开发出来,它会解析到过去。

ResolveToPast.

解析到过去:如果今天是12号,输入的“13”将被解析为上个月的13号,因为偏好是解析到过去。如果今天是3月13日,输入的日期解析为11月15日,且没有指定年份,那么解析后的日期将是去年的11月15日。

其他潜在的解析器将是解析到未来的某一天,或者解析到最接近的日期。

Date Resolver的构造函数接受一个日期作为基准日期(例如,如果类是ResolveToPast,那么任何被考虑为过去的日期都应在此日期之前,任何被考虑为未来的日期都应在此日期之后),以及一个abstractOrderPreference类的实例。在Date Resolver基类构造函数中,abstractOrderPreference类属性RelativeDate被设置为传递给构造函数的日期,CreateDate委托属性被设置为派生类中的CreateDate方法。

解析器

扫描处理时,基本上有两种类型的用户输入:关键字和数字。解析器依赖于关键字字典来确定用户输入的某部分是否是关键字。关键字包括月份、星期几以及其他关键术语。根据这些关键字,会创建用户输入令牌。还会创建第二种类型的令牌,即由数字输入创建的令牌。没有符号的数字假定指定日期的一部分,而带有“+”或“-”符号的数字则指定相对值,该值用于将其他令牌创建的日期按该量进行更改,目前可以是周(通常只有在找到星期几关键字时)或天。这些关键字中的每一个都有一个关联的类型。

  • 月份:一年中的每个月份都有一个这样的关键字。
  • 日期:每周的每一天都有一个这样的关键字。
  • 多重:用于允许一个关键字与多个关键字类型相关联,例如“F”可以是“Friday”(星期五)或“February”(二月)。解析器创建的其他令牌决定了使用哪一个。
  • 序数:这通常用于月份中的星期:第一(1st)、第二(2nd)、第三(3rd)、第四(4th)和最后。它允许用户指定月份中的第n周。
  • 相对:允许关键字与相对于基准日期的某一天相关联。例如,如果今天是3号,用户输入“Yesterday”(昨天),则会返回本月的2号。
  • 特殊:这允许指定节日,目前支持本月周、星期几、日期以及月份的条目。

当用户输入被处理时,使用上述令牌类型的关键字字典(KeywordDictionary)会帮助生成用户输入令牌(UserInputToken)。除了根据上述关键字类型创建令牌外,带加号或减号的数字输入将被封装到“relative”(相对)令牌中,而任何其他数字将被封装到“number”(数字)令牌中。

DateProcessor类的构造函数初始化环境,并提供一个Parse命令来返回日期。

DateProcessor的Parse方法用于将用户输入的字符串转换为日期。该方法将字符串拆分为单词,然后这些单词使用KeywordDictionary类查找应与该单词关联的KeywordToken。然后,使用此数据通过TokenProcessor的AddToken方法创建UserInputToken。在处理完每个单词并将其添加到TokenProcessor后,调用TokenProcessor的GetDate方法,然后返回结果。

初始化时,KeywordDictionary使用构造函数中提供的XmlDocument创建KeywordDictionary。它只有一个公共方法FindKeyword,接受一个字符串作为参数。FindKeyword在Keyword列表中查找与该单词匹配的项,并返回结果。它还会任何数字值与相应的关键字类型关联,并返回该结果。

XML文件

用于解析的许多信息都包含在一个XML文件中。以下显示了XML文件中的数据示例。

<items>
 <item type="month" value="3">MAR[CH]</item>
 <item type="day" value="2">MO[NDAY]</item>
 <item type="multiple" value="DAYOFWEEK:2 MONTH:3">M</item>
 <item type="ordinal" value="1">FI[RST]</item>
 <item type="relative" value="-1">Y[ESTERDAY]</item>
 <item type="special" value="LAST DAYOFWEEK:2 MONTH:5">MEM[ORIAL DAY]</item>
</items>

目前只有一个文件,但理想是为每个支持的语言都拥有一个文件。上面只显示了少数条目。应该注意到,有一个多重类型的条目,其中“M”值同时包含星期几和月份,并且有一个代表3月(March)的月份条目和一个代表星期一(Monday)的星期几条目。这种多重条目允许单个字母“M”代表3月或星期一,具体取决于其他创建的令牌。

入口点

DateProcessor类是项目中唯一的公共类。它目前有一个构造函数,使用枚举来指定顺序偏好和不完整日期的解析方式。用于顺序偏好和日期解析的类都基于这些枚举。此构造函数使用这些枚举、用于查找包含关键字的XML文件的路径以及用作顺序偏好的基准日期的日期。

通过调用公共Parse方法,将要处理的字符串作为方法的唯一参数传入,来找到日期。

已实现的功能

已实现以下类型的输入

  • 数字:最多8位数字的数字输入,可以是连续的数字串,也可以分成月份、日期和年份的独立部分(仍然可以处理其中两项的组合)。输入示例如下:
    • “12”:月份中的第12天

    • “31”:如果月份有31天,则是本月的第31天,否则是3月1日。

    • “66”:6月6日,因为没有66天的月份。

    • “1111”:11月11日,因为预期的输入格式是“mmdd”,并且这是一个有效日期。

    • “666”:2016年6月6日(可能是不同年份,但以6结尾)

    • “6616”:2016年6月6日

    • “23 12”:12月23日,因为23不可能是月份

  • 星期几(例如,“Tue”):这将返回与该星期几匹配的日期。星期几可以是星期几的名称或受支持的缩写(在XML文件中定义)。
  • 相对(例如,“-12”):这将返回自参考日期起指定天数的日期。要识别为相对日期,需要在数字前加上符号。
  • 特殊(例如,“Memorial”):这将返回与XML文件中包含的字符串匹配的日期,通常是节日。特殊日期目前可以通过本月周、月份、星期几或月份和日期来设置。

流程

有3个基本流程

无相对输入:表示在其他处理之后,没有对日期进行数字调整。用户用数字前的符号表示数字调整。

无星期几调整的相对输入:如果输入包含“relative”条目,则将其视为在其他处理之后进行的操作。因此,处理过程与否相对输入相同,然后根据相对条目进行调整。这通常是天数,因此您将包含“+8”以将返回日期调整为8天后的日期。

星期几和相对:第三种路径处理包含星期几和相对的输入。这是因为相对处理在星期几条目存在时会发生变化。它以周而不是天为单位,加号表示未来,“+1”表示下一个是该星期几的日期,相对值的每次增加都代表未来的一周,负数表示过去。 “+0”或“-0”将返回最接近的该星期几的日期。因此,无论用户偏好过去还是未来,星期几都会返回相同的值……此处理还必须考虑到某些用户输入可能被假定为星期几或月份,这取决于其他用户输入。

      public bool GetDate()
        {
            if (Tokens.ContainsKey(EnumKeywordType.relative))
            {
                if (Tokens.ContainsKey(EnumKeywordType.dayOfWeek))
                {
                    var availableTokens = Tokens.Where(i => i.Key != EnumKeywordType.relative
                                && i.Key != EnumKeywordType.dayOfWeek).ToList();
                    Date = TokenCountSwitch(availableTokens, DateAdjuster);
                    Date = Utilities.DateForDayOfWeekRelative(Date,
                                    Tokens.Value(EnumKeywordType.dayOfWeek),
                                    Tokens.Value(EnumKeywordType.relative).ToInt());
                }
                else if (Tokens.ContainsKey(EnumKeywordType.multiple)
                    && (new ProcessSpecial(Tokens.Value(EnumKeywordType.multiple)).DayOfWeek != null))
                {
                    var availableTokens = Tokens.Where(i => i.Key != EnumKeywordType.relative
                                && i.Key != EnumKeywordType.multiple).ToList();
                    Date = TokenCountSwitch(availableTokens, DateAdjuster);
                    Date = Utilities.DateForDayOfWeekRelative(Date,
                               new ProcessSpecial(Tokens.Value(EnumKeywordType.multiple))
                                        .DayOfWeek.ToString(),
                                   Tokens.Value(EnumKeywordType.relative).ToInt());
                }
                else
                {
                    var availableTokens = Tokens.Where(i => i.Key != EnumKeywordType.relative).ToList();
                    Date = TokenCountSwitch(availableTokens, DateAdjuster);
                    Date = Date.AdjustDays(Tokens.Value(EnumKeywordType.relative).ToInt());
                }
            }
            else
            {
                Date = TokenCountSwitch(Tokens, DateAdjuster);
            }
            Tokens.Clear();
            return IsValid;
        }

        private static Date TokenCountSwitch(List<KeyValuePair<EnumKeywordType, string>> Tokens, 
                    abstractDateResolve DateAdjuster)
        {
            switch (Tokens.Count)
            {
                case 0:
                    return DateAdjuster.RelativeDate;
                case 1:
                    return OneToken(Tokens.First(), DateAdjuster);
                case 2:
                    return TwoToken(Tokens, DateAdjuster);
                case 3:
                    return ThreeToken(Tokens, DateAdjuster);
            }
            throw new ArgumentException("too many tokens");
        }

双重输入

数字和月份(例如,“Nov 12”):月份将匹配月份名称或受支持的缩写(在XML文件中定义)。数字可以最多为6位数字,或者分为两个连续的数字组。

相对星期几(例如,“+4 fr”):这将返回一个日期,该日期对应于由相对值(带符号的数字值)指定的周数的星期几。

序数星期几(例如,“First Friday”):将返回特定月份的特定星期几,是当前月份还是相邻月份,具体取决于所选的解析方式(过去、未来……)。

三重输入

序数星期几和月份(例如,“First Friday Nov”):将返回特定月份的特定星期几,是当前年份还是相邻年份,具体取决于所选的解析方式(过去、未来……)。

Date类

所有计算都将在Date类中返回。该类被部分使用是因为它包含了在日期计算期间使用的方法,但更重要的是,它有一个ErrorMessage属性,除非处理输入时出现问题,否则该属性为null。ToString方法将在其非空时返回此字符串,或者使用当前文化的日期,因为它使用DataTime的ToShortDateString方法。

Using the Code

要使用该代码,需要初始化Date Processor的一个实例。

 var dateProcessor = new DateProcessor(DateTime.Now, "QuickDateStrings.xml",
  EnumDateResolve.RelativePast, EnumOrderPreference.MDY);

目前需要提供输入日期将相对的日期,包含关键字的XML文件的路径,以及指示日期解析偏好和输入顺序偏好的枚举。此代码仍处于非常原始的状态,最终可能会希望XML文件由当前文化确定,并且相对日期默认为当前日期。我不默认日期是因为我有一个测试文件,并且需要一个定义的相对日期,以便可以将处理结果与预定的预期值进行比较。

要使用实例,只需调用Parse方法并将要处理的字符串作为方法的唯一参数传入。

var dt = dateProcessor.Parse(input);

目前结果是QuickDateEntryLibrary项目中定义的Date类。该类中定义了一个ToDateTime方法,用于转换为DateTime。

示例

示例包含多个项目,提供了多种测试/使用Quick Date Library的方法。这些项目目前只将日期解析到过去,并且只设置为MDY顺序偏好,并使用一个包含美国英语字符串(用于节日、月份名称、星期几名称等)的XML文件。

Quick Date Library项目(QuickDateEntryLibrary)

这是应该包含在您的解决方案中使用此功能的部分。

控制台项目(QuickDataEntryConsoleTest)

这是一个控制台应用程序,允许输入不同的字符串,并将显示结果,包括任何错误消息。WPF应用程序不提供相同级别的详细信息

WPF项目(QuickDateWpf)

这是一个展示Quick Date如何在WPF应用程序中使用的项目。它是一个非常简单的实现,使用了IValueConverter。问题是,没有办法向用户提供错误反馈,因为Converter只为ViewModel转换,然后任何错误信息都会丢失。另外,需要另一个可以获得焦点的控件(使用了Result TextBox),因为更新只在具有日期输入的控件失去焦点后进行。我正在研究一个更好的实现,并乐于接受建议。

单元测试(QuickDateUnitTest)

单元测试使用CSV文件(“TestQuickDate.csv”)来提供测试值。结果目前输出到另一个CSV文件(“TestResults.csv”)。然后它将在Excel中打开结果CSV文件。将来,我想浏览并确定CSV中所有输入字符串和相对日期的正确值,然后可以有一个大大改进的版本。

输入文本“fr 3rd”

在2017年6月8日,结果将是2017年5月19日

结论

这篇文章已经酝酿了很长时间,有好几年了。部分原因是,我之前在解析方面没有太多经验,也不确定我是否喜欢自己设计的方案,并且它一直在演变。无疑有了一些重大改进,这次,在我进行了一些对核心代码的小调整后,我认为它应该是可以接受的,并且我确实想将这个概念分享给社区。

我个人经常使用星期几和纯数字输入(这正是我最初实现的所有功能),而且我非常喜欢它。使用这种输入方式需要稍微思考一下,因为您必须考虑输入可能如何被混淆(例如,“31”可能导致本月的第31天或“3月1日”,而32则总是导致“3月2日”),但我对这些改进感到非常满意。此后,我添加了许多其他增强功能,因为我认为它们可能对其他用户有用,并且表明该设计可以支持很多灵活性。

我不会说这是DatePicker的替代品,而更像是它的补充。DatePicker确实提供了一种查看和选择日期的好方法,但它并不完美。

当然还有一些其他的想法可能值得添加到这个概念中。希望该设计足够灵活,能够处理它们。请提供反馈。

历史

2017/06/12:初始版本。

© . All rights reserved.